Content Script HMR System Architecture in CRXJS: A Technical Deep Dive

CRXJS implements a three-layer hot-module-replacement pipeline that intercepts Vite's native HMR events, translates them into Chrome extension-specific payloads, and forwards them through Chrome runtime ports to content scripts running in isolated worlds.

The CRXJS Chrome extension tools provide a seamless development experience for browser extensions by enabling hot module replacement (HMR) for content scripts. This content script HMR system bridges Vite's development server with Chrome's extension runtime, allowing developers to update content script code instantly without reloading the entire extension or the host page.

Three-Layer Architecture Overview

The content script HMR system consists of three cooperating layers that exchange information through RxJS streams and Chrome's runtime.Port:

Layer Responsibility Core Files
Vite server & plugin-HMR Intercepts Vite's HMR events, decorates the websocket, and translates them into CRX-specific payloads plugin-hmr.ts
Content-script emitter & virtual loader Generates loader assets for each content script, injects the HMR runtime, and rewrites module URLs plugin-contentScripts.ts, contentScripts.ts
Client-side HMR port Runs inside the content script, maintains a persistent connection to the background service worker, and forwards CRX-payloads to the Vite client hmr-content-port.ts

Server-Side HMR Processing

The server-side logic resides primarily in packages/vite-plugin/src/node/plugin-hmr.ts. This plugin hijacks Vite's native HMR system to support Chrome extension-specific requirements.

Intercepting Vite WebSocket Messages

In the configureServer hook, the plugin wraps server.ws.send to intercept all HMR payloads. When Vite sends a normal HMR payload (where type !== "error"), the wrapper forwards the original payload to an internal RxJS Subject called hmrPayload$. Error messages are re-packaged as a custom CRX event (crx:content-script-payload) before transmission.

The plugin also subscribes the crxHMRPayload$ stream to the websocket, ensuring that any CRX-specific payloads—such as crxRuntimeReload—reach the browser.

Detecting Module Changes

The handleHotUpdate hook collects three sets of paths for each update: relative source files, absolute file-system URLs, and virtual modules (ids that start with \0 or /@id/__x00__).

For background scripts, changes trigger a full extension reload via crxRuntimeReload. For content scripts, the system checks whether the changed file is a direct dependency or a CSS virtual module using isContentCssId. When detected, the corresponding virtual asset is updated via the update() method, which writes the new code into the dev server's in-memory file system.

Content Script Loader Generation

The content script HMR system generates specialized loader assets through packages/vite-plugin/src/node/plugin-contentScripts.ts and packages/vite-plugin/src/node/contentScripts.ts.

Virtual Module Emission

When Vite starts in serve mode, pluginContentScripts (configured with apply: "serve") creates a Subscription that watches contentScripts.change$. Whenever a new script is added, the plugin emits a virtual loader asset via the Rollup emitFile API. The loader code is built from templates in contentScripts.ts using functions like createDevLoader and createProLoader.

The virtual file hmr-content-port.ts—defined in packages/vite-plugin/src/node/virtualFileIds.ts as contentHmrPortId—is automatically imported by every content script that Vite serves, exposing the HMRPort class to the runtime.

URL Rewriting for Development

During the renderCrxDevScript phase, the plugin rewrites the hot-module-context URL (createHotContext("…")) to the virtual file name that corresponds to the content-script asset (getFileName). This ensures the Vite client knows the exact module that should be hot-replaced when updates occur.

Client-Side Runtime Bridge

The client-side component runs inside the loaded content script via packages/vite-plugin/src/client/es/hmr-content-port.ts.

The HMRPort Class

The HMRPort class opens a Chrome runtime port immediately upon construction using chrome.runtime.connect({name: '@crx/client'}). It maintains a persistent connection to the background service worker and periodically pings it to keep the service worker alive. The timeout value is injected from the Vite plugin as __CRX_HMR_TIMEOUT__.

Message Forwarding to Vite Client

Incoming messages through the port are parsed to identify CRX payloads—messages where type === 'custom' && event.startsWith('crx:'). These payloads are either forwarded to the Vite client using forward(JSON.stringify(payload.data)) or trigger a delayed page reload in the case of crx:runtime-reload.

The client also exposes addEventListener and send methods, allowing content scripts to interact with the port directly if needed for custom HMR handling.

Configuration Example

To enable content script HMR in your project, configure the CRXJS plugin in your vite.config.ts:

// vite.config.ts
import { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'

export default defineConfig({
  plugins: [
    crx({
      contentScripts: {
        main: {
          matches: ['<all_urls>'],
          js: ['src/content/main.ts'],
          hmrTimeout: 5000, // optional: custom ping timeout in ms
        },
      },
    }),
  ],
})

In your content script source file, standard Vite HMR APIs work automatically:

// src/content/main.ts
console.log('content script loaded')

if (import.meta.hot) {
  import.meta.hot.accept(() => {
    console.log('content script hot-reloaded')
  })
}

During vite dev, the plugin emits a loader asset (src/content/main-loader.js) that imports the generated hmr-content-port.ts. The background service worker receives CRX payloads and forwards them through the Chrome port to the loader, which finally invokes Vite's import.meta.hot.accept.

Summary

  • Three-layer architecture: The content script HMR system combines Vite server interception (plugin-hmr.ts), virtual loader generation (plugin-contentScripts.ts), and client-side port management (hmr-content-port.ts).
  • RxJS-based communication: Server-side layers exchange data through hmrPayload$ and crxHMRPayload$ streams defined in fileWriter-hmr.ts, while the client uses Chrome's runtime.Port.
  • Virtual module handling: The system emits virtual loader assets and rewrites module URLs to ensure Vite's HMR client can locate scripts within the Chrome extension context.
  • Background forwarding: The HMRPort class maintains persistent connections to the service worker, forwarding CRX-specific payloads to Vite's client-side HMR runtime.

Frequently Asked Questions

How does CRXJS intercept Vite's native HMR websocket messages?

In packages/vite-plugin/src/node/plugin-hmr.ts, the configureServer hook wraps server.ws.send to intercept payloads. Normal HMR messages are forwarded to an RxJS Subject called hmrPayload$, while errors are re-packaged as crx:content-script-payload events. The plugin also pipes crxHMRPayload$ back to the websocket to send Chrome extension-specific commands to the browser.

What triggers a content script update versus a full extension reload?

The handleHotUpdate hook in plugin-hmr.ts differentiates between module types. Background script changes trigger crxRuntimeReload for a full extension restart. Content script changes are detected by checking if the modified file is a direct dependency or CSS virtual module (via isContentCssId), which then updates the virtual asset in-memory without reloading the entire extension.

How does the client-side port keep the service worker alive during development?

The HMRPort class in packages/vite-plugin/src/client/es/hmr-content-port.ts opens a Chrome runtime port with chrome.runtime.connect({name: '@crx/client'}) and sends periodic ping messages based on the __CRX_HMR_TIMEOUT__ value injected by the server plugin. This prevents the service worker from terminating due to inactivity while maintaining the HMR communication channel.

Can developers customize the HMR timeout for content scripts?

Yes. In vite.config.ts, content script entries accept an optional hmrTimeout property (in milliseconds) that controls how frequently the client-side port pings the background service worker. This value is injected into the content script as __CRX_HMR_TIMEOUT__ and used by the HMRPort class to maintain the connection.

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 →