How the Chrome Extension Manifest Is Transformed by vite-plugin-manifest.ts in CRXJS

The vite-plugin-manifest.ts plugin in the crxjs/chrome-extension-tools repository orchestrates a three-stage pipeline—load and validate, transform and emit, and generate bundle—to convert developer-friendly manifest definitions into production-ready Chrome extension manifest.json files with correct hashed filenames and Vite dev-server integration.

The CRXJS Chrome extension tools use a sophisticated Vite plugin architecture to handle Chrome extension manifests. Understanding how vite-plugin-manifest.ts transforms your manifest.json is essential for debugging build issues and creating custom plugins that modify extension behavior during the build lifecycle.

The Three-Stage Transformation Pipeline

The transformation proceeds through three distinct stages that correspond to the standard Vite/Rollup hook order. Each stage handles specific responsibilities, from initial validation to final file emission.

Stage 1: Load and Initialise

During the initialization phase, the plugin captures the user configuration and prepares the Vite environment for Chrome extension development.

  • config hook (lines 45‑55) – Resolves the user-provided manifest, which may be a plain object or a function returning a manifest. It strictly validates that manifest_version === 3 and throws an error if the manifest does not meet this requirement.
  • configResolved hook (lines 91‑98) – Captures references to other registered CRX plugins for later use in the transformation pipeline.
  • Development optimization – When command === 'serve', the plugin augments Vite’s optimizeDeps.entries array to ensure all manifest entry files (JavaScript, CSS, HTML, and service workers) are pre-bundled by the dev server.

Stage 2: Transform and Emit

The transform hook (post-enforce) decodes the manifest and allows other plugins to mutate it before emitting the actual script files.

  • Virtual module decoding – The plugin decodes the emitted manifest module (manifestId), which is served as a virtual module crx-manifest.js (referenced as crx:manifest-loader in lines 21‑24).
  • Plugin mutation – The plugin calls transformCrxManifest on every registered CRX plugin (transformation loop lines 86‑99), allowing other plugins to add fields, rewrite values, or modify the manifest structure.
  • Script emission – The plugin emits content-script files, CSS synthetic loaders, and background scripts as Rollup chunks. It stores a mapping of each script to a loader record in contentScripts.
  • Mode-specific logic – Different code paths handle file emission for development versus production:
    • Serve mode (lines 101‑154) – Registers synthetic loader entries for content scripts to enable HMR.
    • Build mode (lines 155‑276) – Emits real Rollup chunks with hashed filenames for optimal caching.

Stage 3: Generate Bundle and Render

During the generateBundle hook (post-enforce), the plugin finalizes filenames, executes final mutation hooks, and writes the manifest.json to disk.

  • Bundle retrieval (lines 311‑315) – Retrieves the manifest module from the bundle and decodes it for final processing.
  • Filename replacement – Updates file references in the manifest to match the actual emitted file names:
    • Development (lines 318‑346) – Replaces placeholders with synthetic loader URLs like loader-<hash>.js.
    • Production (lines 347‑380) – Replaces placeholders with the final hashed chunk names (e.g., contentScript-<hash>.js).
  • Final plugin hooks (lines 384‑406) – Executes renderCrxManifest hooks on all CRX plugins, providing a final opportunity to tweak the manifest after file names are known.
  • Asset copying (lines 410‑457) – Copies any manifest assets (icons, locales, rulesets, etc.) that were not automatically emitted by the bundler.
  • Development loading page (lines 460‑480) – In serve mode, creates a tiny "loading page" asset that injects the Vite dev-server script into each HTML page.
  • Final write (lines 482‑495) – Overwrites or creates the final manifest.json in the bundle and removes the temporary crx-manifest.js chunk.

Practical Implementation Examples

Basic Vite Configuration

Configure the plugin to load your manifest definition:

// vite.config.ts
import { defineConfig } from 'vite'
import { crx } from 'crxjs/vite-plugin'   // re‑exports plugin‑manifest internally

export default defineConfig({
  plugins: [
    crx({
      manifest: './src/manifest.ts',   // can be a plain object or a function
    }),
  ],
})

Modifying the Manifest During Transform

Create a custom CRX plugin to add permissions before files are emitted:

// my-permissions-plugin.ts
export const addPermission = {
  name: 'my:add-permission',
  async transformCrxManifest(manifest) {
    // ensure the permission array exists
    ;(manifest.permissions ??= []).push('storage')
    return manifest
  },
}

Register the plugin in your Vite config:

// vite.config.ts
import { crx } from 'crxjs/vite-plugin'
import { addPermission } from './my-permissions-plugin'

export default {
  plugins: [crx({ manifest: './manifest.ts' }), addPermission],
}

Injecting Build Metadata After Filename Resolution

Use the renderCrxManifest hook to add build-specific data after file names are known:

export const injectBuildInfo = {
  name: 'my:build-info',
  async renderCrxManifest(manifest, bundle) {
    manifest.version_name = `build-${new Date().toISOString()}`
    return manifest
  },
}

Inspecting the Manifest During Development

The plugin serves the manifest as a virtual module during development. You can inspect it by importing the resolved virtual ID:

npm run dev

Then in your code:

// The plugin emits a virtual module `crx-manifest.js`
import manifest from '/@fs/src/manifest.ts'   // Vite resolves the virtual ID

console.log('Decoded manifest during dev:', manifest)

Key Source Files in the Transformation Pipeline

Summary

  • The transformation occurs in three distinct stages aligned with Vite/Rollup hooks: initialization, transform/emit, and bundle generation.
  • transformCrxManifest allows mutation before file emission, while renderCrxManifest runs after filenames are finalized for build-specific modifications.
  • The plugin handles different emission strategies for development (synthetic loaders for HMR) versus production (hashed chunks for caching).
  • Static assets referenced in the manifest are copied automatically during the generateBundle phase if not emitted by the bundler.
  • The final manifest.json is written with accurate file references, and the temporary virtual module crx-manifest.js is removed from the output.

Frequently Asked Questions

What validates that the manifest uses Manifest V3?

The config hook in plugin-manifest.ts (lines 45‑55) reads the user-provided manifest and strictly validates that manifest_version === 3, throwing an error if this requirement is not met.

How does the plugin handle content scripts differently in development versus production?

During serve mode (development), the plugin registers synthetic loader entries for content scripts (lines 101‑154) to enable Vite's dev-server HMR, while in build mode (production), it emits actual Rollup chunks with hashed filenames (lines 155‑276) for optimal caching and deployment.

When can plugins modify the manifest after filenames are known?

The renderCrxManifest hook executes during generateBundle (lines 384‑406) after file names are updated to their final values, providing a final opportunity for CRX plugins to inject build-specific metadata or modify fields that depend on the emitted output.

How are static assets like icons processed if not imported in code?

During the generateBundle phase (lines 410‑457), the plugin copies any manifest assets—including icons, locales, and rulesets—that were not automatically emitted by the bundler, ensuring all referenced files exist in the final extension package.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →