How CrxPlugin Extends VitePlugin: transformCrxManifest and renderCrxManifest Explained
CrxPlugin extends Vite's standard Plugin type by adding two Chrome-extension-specific lifecycle hooks—transformCrxManifest for early-stage manifest mutations and renderCrxManifest for final asset-aware modifications before writing manifest.json.
The CrxPlugin interface in the crxjs/chrome-extension-tools repository provides a thin wrapper around Vite's native plugin system, allowing developers to treat Chrome extension manifests as first-class build assets. By extending the base VitePlugin type with two specialized hooks, CRXJS enables programmatic control over manifest generation at distinct phases of the Vite build lifecycle.
What Is the CrxPlugin Interface?
CrxPlugin is defined in packages/vite-plugin/src/node/types.ts as a direct extension of Vite's standard Plugin type. This architectural choice means all standard Vite hooks—config, buildStart, resolveId, load, and transform—remain fully available to Chrome extension plugin authors.
The interface augmentation introduces two optional methods specifically for manifest manipulation:
transformCrxManifest: Executes during the Vite transform phase when processing the virtualcrx-manifest.jsfilerenderCrxManifest: Executes after the bundle generation completes, just beforemanifest.jsonis written to disk
Because CrxPlugin preserves the entire Vite plugin API, existing Vite plugins work alongside CRX-specific logic without modification.
The Two Chrome Extension Lifecycle Hooks
transformCrxManifest: Early-Stage Manifest Transformation
The transformCrxManifest hook runs before any assets are emitted to disk. It receives a mutable copy of the ManifestV3 object and can return a transformed version, null, or undefined.
When to use it:
- Injecting default permissions that must exist before resource generation
- Normalizing path structures across different environments
- Adding runtime flags or metadata early in the pipeline
If the hook returns a value, it replaces the current manifest for the next plugin in the execution chain. Returning null or undefined leaves the manifest unchanged and passes the existing object forward.
renderCrxManifest: Final Asset-Aware Rendering
The renderCrxManifest hook executes in the generateBundle phase after all chunks and assets have been processed. It receives both the current ManifestV3 object and the complete Rollup bundle object containing final filenames and hashes.
When to use it:
- Mapping placeholder script names to actual hashed output filenames (e.g.,
generated.js→generated-[hash].js) - Injecting content script paths that reference emitted chunk names
- Performing final validation against the actual build output
This hook returns the final manifest that gets written to manifest.json. Like the transform hook, returning null or undefined preserves the current manifest state.
How the Hooks Are Executed in the Vite Lifecycle
The hook invocations are orchestrated in packages/vite-plugin/src/node/plugin-manifest.ts, the core plugin responsible for manifest loading, transformation, and emission.
Transform phase execution:
At line 88 of plugin-manifest.ts, after the virtual manifest file is loaded, each registered plugin's transformCrxManifest method is called sequentially. This occurs within Vite's standard transform hook, allowing the manifest to pass through the same pipeline as other source files.
Render phase execution:
At line 85 of plugin-manifest.ts, once generateBundle finishes processing all assets, the plugins' renderCrxManifest hooks run in order. This timing ensures the bundle object contains the final, hashed filenames that will actually exist in the output directory.
// Simplified conceptual flow from plugin-manifest.ts
async function processManifest(plugins: CrxPlugin[], manifest: ManifestV3, bundle: OutputBundle) {
// Transform phase - line 88 region
for (const plugin of plugins) {
if (plugin.transformCrxManifest) {
const result = await plugin.transformCrxManifest.call(context, manifest)
if (result != null) manifest = result
}
}
// ... bundle generation occurs ...
// Render phase - line 85 region
for (const plugin of plugins) {
if (plugin.renderCrxManifest) {
const result = await plugin.renderCrxManifest.call(context, manifest, bundle)
if (result != null) manifest = result
}
}
return manifest
}
Practical Implementation: Creating a Custom CrxPlugin
The following example demonstrates a complete CrxPlugin implementation that utilizes both hooks to manipulate a Chrome extension manifest:
// my-crx-plugin.ts
import type { CrxPluginFn, ManifestV3 } from '@crxjs/vite-plugin'
export const myCrxPlugin: CrxPluginFn = (options) => ({
name: 'my-crx-plugin',
// Standard Vite hook remains available
configResolved(config) {
console.log('Vite config resolved, mode:', config.mode)
},
// Transform hook: Add default permissions before asset generation
async transformCrxManifest(this, manifest: ManifestV3) {
if (!manifest.permissions?.includes('storage')) {
manifest.permissions = [...(manifest.permissions ?? []), 'storage']
}
console.log('transformCrxManifest applied')
return manifest
},
// Render hook: Map generated chunks to content scripts
async renderCrxManifest(this, manifest: ManifestV3, bundle) {
const generatedFile = Object.values(bundle).find(
(f) => f.type === 'chunk' && f.name === 'generated',
)
if (generatedFile && manifest.content_scripts?.[0]) {
manifest.content_scripts[0].js = [
...(manifest.content_scripts[0].js ?? []),
generatedFile.fileName
]
}
console.log('renderCrxManifest applied')
return manifest
},
})
Register the plugin alongside the core CRX plugin in your Vite configuration:
// vite.config.ts
import { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'
import { myCrxPlugin } from './my-crx-plugin'
export default defineConfig({
plugins: [
crx({ manifest: './src/manifest.json' }),
myCrxPlugin()
],
})
Key Source Files in the Repository
Understanding the implementation requires examining these specific files in the crxjs/chrome-extension-tools codebase:
packages/vite-plugin/src/node/types.ts– Declares theCrxPlugininterface extendingVitePlugin, including the optionaltransformCrxManifestandrenderCrxManifestmethod signatures at line 53packages/vite-plugin/src/node/plugin-manifest.ts– Contains the actual hook invocation logic at lines 85 (render) and 88 (transform), handling the virtualcrx-manifest.jsmodule and finalmanifest.jsonemissiontests/vite-compat/plugins/plugins.test.ts– Validates hook execution during both development (serve) and production (build) modes
Summary
- CrxPlugin extends VitePlugin by adding two optional Chrome extension-specific hooks while preserving all standard Vite plugin functionality
transformCrxManifestruns during the transform phase for the virtual manifest file, receiving a mutableManifestV3object for early modificationsrenderCrxManifestexecutes after bundle generation, receiving the manifest and Rollup bundle to perform final asset-aware adjustments- Both hooks follow Vite's plugin convention: return a value to replace the manifest, or return
null/undefinedto leave it unchanged - The implementation lives in
packages/vite-plugin/src/node/types.ts(interface) andpackages/vite-plugin/src/node/plugin-manifest.ts(execution logic)
Frequently Asked Questions
What is the difference between transformCrxManifest and renderCrxManifest?
transformCrxManifest executes earlier in the build process, during Vite's transform phase, allowing you to modify the manifest before any assets are generated or hashed. renderCrxManifest runs later, after Rollup has generated the final bundle, giving you access to actual output filenames and hashes for accurate path injection.
Can I use standard Vite hooks alongside CrxPlugin-specific hooks?
Yes. Because CrxPlugin extends the base Vite Plugin type, you can implement any standard Vite hook—such as config, buildStart, or resolveId—within the same plugin object that contains transformCrxManifest and renderCrxManifest. The CRXJS plugin system treats these as additive capabilities rather than replacements.
What happens if I return null from transformCrxManifest or renderCrxManifest?
Returning null or undefined from either hook leaves the manifest unchanged and passes the current manifest object to the next plugin in the chain. This behavior follows Vite's established plugin pattern, where hooks only mutate state when explicitly returning a replacement value.
Where are the CrxPlugin hook invocations implemented in the source code?
The hook execution logic resides in packages/vite-plugin/src/node/plugin-manifest.ts. Specifically, transformCrxManifest is invoked around line 88 during the virtual module transform phase, and renderCrxManifest is called around line 85 within the generateBundle lifecycle hook, just before the final manifest is written to disk.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →