CrxHMRPayload in CRXJS: Architecture, Purpose, and Chrome Extension HMR Implementation
CrxHMRPayload is a custom Vite HMR event type that tunnels hot-module-replacement messages through Chrome extension service workers to content scripts, bypassing CSP and isolated context restrictions.
The CRXJS Vite plugin enables modern development workflows for browser extensions by bridging Vite's hot-module-replacement system with Chrome's isolated execution contexts. At the heart of this bridge lies CrxHMRPayload, a specialized message format defined in packages/vite-plugin/src/node/types.ts that wraps standard Vite HMR events for secure transmission to content scripts.
What Is CrxHMRPayload?
CrxHMRPayload is a discriminated union type that extends Vite's native HMRPayload with extension-specific semantics. Defined at lines 118-128 of packages/vite-plugin/src/node/types.ts, it supports two distinct event variants:
export type CrxHMRPayload =
| {
type: 'custom'
event: 'crx:runtime-reload'
}
| {
type: 'custom'
event: 'crx:content-script-payload'
data: HMRPayload
}
The crx:runtime-reload event signals that the background script or manifest has changed, requiring a full extension reload via chrome.runtime.reload(). The crx:content-script-payload variant wraps ordinary Vite HMR updates (CSS changes, module updates, or full reloads) for delivery to isolated content scripts.
Why CRXJS Needs a Custom HMR Payload
Standard Vite HMR relies on WebSocket connections between the dev server and the browser. Chrome extensions present three architectural barriers that prevent direct usage:
- Content Security Policy (CSP) – Extensions enforce strict CSP that blocks inline scripts and remote WebSocket connections from content scripts.
- Isolated Execution Contexts – Content scripts run in isolated worlds separate from the service worker and background scripts, preventing direct memory sharing.
- Cross-Origin Restrictions – Content scripts injected into third-party origins cannot establish WebSocket connections back to the Vite dev server.
CrxHMRPayload solves these issues by using the extension's service worker as a proxy. The service worker maintains the WebSocket connection to Vite, receives HMR messages, wraps them in CrxHMRPayload objects, and rebroadcasts them to content scripts via Chrome's runtime.connect() API.
Architecture: From Vite Server to Content Script
The payload travels through three distinct stages: server-side transformation, serialization, and client-side consumption.
Server-Side Transformation in plugin-hmr.ts
In packages/vite-plugin/src/node/plugin-hmr.ts (lines 56-73), the Vite plugin decorates the server's WebSocket send method. This interceptor captures all HMR events and routes them through the CRXJS processing pipeline:
// From plugin-hmr.ts
const originalSend = server.ws.send.bind(server.ws)
server.ws.send = (payload: HMRPayload) => {
// Errors are converted to crx:content-script-payload
// Other events flow into hmrPayload$ stream
return originalSend(payload)
}
The plugin also defines the runtime reload constant at lines 18-21:
export const crxRuntimeReload: CrxHMRPayload = {
type: 'custom',
event: 'crx:runtime-reload',
}
Payload Serialization in fileWriter-hmr.ts
The packages/vite-plugin/src/node/fileWriter-hmr.ts module (lines 23-33 and 107-114) transforms raw Vite HMR events into the final CrxHMRPayload format. This module filters out Vite's built-in custom payloads, buffers updates until all files are ready, and maps events to the normalized shape:
// Observable pipeline from fileWriter-hmr.ts
export const crxHMRPayload$: Observable<CrxHMRPayload> = hmrPayload$.pipe(
filter(p => !isCustomPayload(p)),
// Buffering and file readiness logic...
map(data => ({
type: 'custom',
event: 'crx:content-script-payload',
data,
}))
)
At lines 107-114, the module ensures that full-reload, prune, and update events from Vite are properly wrapped before transmission.
Client-Side Consumption in hmr-client-worker.ts
The service worker client in packages/vite-plugin/src/client/es/hmr-client-worker.ts (lines 16-33 and 31-46) completes the circuit. This script runs inside the extension's service worker, maintains the WebSocket connection to the Vite dev server, and handles incoming CrxHMRPayload messages:
// Type guard from hmr-client-worker.ts
function isCrxHmrPayload(x: HMRPayload): x is CrxHMRPayload {
return x.type === 'custom' && x.event.startsWith('crx:')
}
// WebSocket message handler
socket.addEventListener('message', ({ data }) => {
const payload = JSON.parse(data) as HMRPayload
if (isCrxHmrPayload(payload)) {
handleCrxHmrPayload(payload)
}
})
// Handler implementation
function handleCrxHmrPayload(payload: CrxHMRPayload) {
notifyContentScripts(payload)
if (payload.event === 'crx:runtime-reload') {
chrome.runtime.reload()
}
}
When the worker receives a crx:content-script-payload, it forwards the wrapped data to all connected content script ports. For crx:runtime-reload, it triggers a full extension restart.
Summary
- CrxHMRPayload is a custom Vite HMR event type defined in
types.tsthat wraps standard HMR updates for Chrome extension compatibility. - The payload uses two event variants:
crx:runtime-reloadfor full extension restarts andcrx:content-script-payloadfor standard module updates. - Server-side transformation occurs in
plugin-hmr.tsandfileWriter-hmr.ts, where Vite HMR events are intercepted, buffered, and wrapped. - Client-side handling in
hmr-client-worker.tsreceives payloads via WebSocket and rebroadcasts them to content scripts through Chrome's runtime messaging API. - This architecture bypasses extension CSP and isolated context restrictions while maintaining Vite's instant HMR experience.
Frequently Asked Questions
What is the difference between crx:runtime-reload and crx:content-script-payload?
The crx:runtime-reload event signals that the service worker, background script, or manifest has changed, requiring a full extension reload via chrome.runtime.reload(). The crx:content-script-payload event wraps standard Vite HMR updates (CSS changes, JavaScript module updates, or pruning) and forwards them to content scripts without restarting the entire extension.
Why can't Vite's standard HMR work directly in Chrome extensions?
Standard Vite HMR relies on WebSocket connections between the dev server and the browser window. Chrome extensions enforce Content Security Policy (CSP) restrictions that block remote WebSocket connections from content scripts, and content scripts run in isolated execution contexts separate from the service worker. CrxHMRPayload solves this by using the service worker as a proxy to receive WebSocket messages and rebroadcast them to content scripts via Chrome's runtime.connect() API.
How does the service worker forward HMR messages to content scripts?
The service worker script (hmr-client-worker.ts) maintains a WebSocket connection to the Vite dev server. When it receives a CrxHMRPayload, it iterates over all active chrome.runtime.Port connections to content scripts and calls port.postMessage() with the payload. Content scripts listen for these messages via chrome.runtime.onConnect and apply the HMR updates using Vite's client-side API.
Where is CrxHMRPayload defined in the source code?
The TypeScript definition resides in packages/vite-plugin/src/node/types.ts at lines 118-128. This file declares the discriminated union type with its two variants: crx:runtime-reload and crx:content-script-payload. The runtime implementation and emission logic are found in plugin-hmr.ts and fileWriter-hmr.ts, while consumption logic lives in hmr-client-worker.ts.
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 →