# CrxHMRPayload in CRXJS: Architecture, Purpose, and Chrome Extension HMR Implementation

> Discover CrxHMRPayload in CRXJS. This Vite HMR event type enables hot module replacement in Chrome extensions by tunneling messages through service workers, overcoming CSP and isolation.

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

---

**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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/types.ts), it supports two distinct event variants:

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

1. **Content Security Policy (CSP)** – Extensions enforce strict CSP that blocks inline scripts and remote WebSocket connections from content scripts.
2. **Isolated Execution Contexts** – Content scripts run in isolated worlds separate from the service worker and background scripts, preventing direct memory sharing.
3. **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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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:

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

```typescript
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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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:

```typescript
// 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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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:

```typescript
// 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.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/types.ts) that wraps standard HMR updates for Chrome extension compatibility.
- The payload uses two event variants: `crx:runtime-reload` for full extension restarts and `crx:content-script-payload` for standard module updates.
- Server-side transformation occurs in [`plugin-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-hmr.ts) and [`fileWriter-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/fileWriter-hmr.ts), where Vite HMR events are intercepted, buffered, and wrapped.
- Client-side handling in [`hmr-client-worker.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/hmr-client-worker.ts) receives 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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/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`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-hmr.ts) and [`fileWriter-hmr.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/fileWriter-hmr.ts), while consumption logic lives in [`hmr-client-worker.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/hmr-client-worker.ts).