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

> Explore the CRXJS content script HMR system architecture. Learn how it achieves hot module replacement for Chrome extensions using a three-layer pipeline and Vite integration.

- Repository: [crxjs/chrome-extension-tools](https://github.com/crxjs/chrome-extension-tools)
- Tags: deep-dive
- Published: 2026-02-28

---

**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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-contentScripts.ts), [`contentScripts.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/hmr-content-port.ts) |

## Server-Side HMR Processing

The server-side logic resides primarily in [`packages/vite-plugin/src/node/plugin-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/plugin-contentScripts.ts) and [`packages/vite-plugin/src/node/contentScripts.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/contentScripts.ts) using functions like `createDevLoader` and `createProLoader`.

The virtual file [`hmr-content-port.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/hmr-content-port.ts)—defined in [`packages/vite-plugin/src/node/virtualFileIds.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/vite.config.ts):

```typescript
// 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:

```typescript
// 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`](https://github.com/crxjs/chrome-extension-tools/blob/main/src/content/main-loader.js)) that imports the generated [`hmr-content-port.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-hmr.ts)), virtual loader generation ([`plugin-contentScripts.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-contentScripts.ts)), and client-side port management ([`hmr-content-port.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/hmr-content-port.ts)).
- **RxJS-based communication**: Server-side layers exchange data through `hmrPayload$` and `crxHMRPayload$` streams defined in [`fileWriter-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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.