How plugin-background.ts Handles Service Worker Compilation for Chrome Extensions

The plugin-background.ts file in CRXJS orchestrates service worker compilation by injecting a virtual HMR client during development, emitting a root-level loader asset that imports the actual worker, and patching the extension manifest to reference this loader instead of the source file.

The crxjs/chrome-extension-tools repository transforms Vite into a first-class build system for browser extensions through sophisticated service worker compilation. At packages/vite-plugin/src/node/plugin-background.ts, the pluginBackground export implements a dual-stage Vite plugin that bridges the gap between modern ES modules and the extension platform's strict service worker requirements. This file ensures your background script maintains proper scope while supporting hot module replacement (HMR) during vite serve and clean bundling during vite build.

The Dual-Plugin Architecture

The pluginBackground export combines two distinct sub-plugins that operate at different lifecycle stages. The first handles virtual module resolution for development-time HMR, while the second manages loader generation and manifest mutation.

Sub-Plugin 1: Virtual HMR Client Resolution

During development, the plugin registers a virtual module to enable live-reloading of the service worker. In the resolveId hook (lines 30-35), the plugin intercepts requests for /${workerClientId}:

// packages/vite-plugin/src/node/plugin-background.ts#L30-L35
resolveId(source) {
  if (source === `/${workerClientId}`) return workerClientId
}

When Vite requests the content of this virtual ID, the load hook (lines 36-41) injects the dev server base URL into the HMR client source:

// Lines 36-41
const base = server.config.server?.origin ?? server.config.server?.host ?? 'localhost'
return defineClientValues(
  workerHmrClient.replace('__BASE__', JSON.stringify(base)), 
  config
)

Sub-Plugin 2: Background Loader Generation

The second sub-plugin, registered as crx:background-loader-file, executes during the config and renderCrxManifest phases. It determines the target browser (Chrome or Firefox) and constructs the physical loader file that the extension will actually execute.

Step-by-Step Compilation Process

Service worker compilation follows an eight-step pipeline that transforms your source files into a properly scoped, manifest-compliant background script.

1. Browser Detection and Storage

The plugin reads user options during the config hook (lines 48-52) to determine the target browser:

// Lines 48-52
const opts = getOptions()
browser = opts.browser || 'chrome'

This value persists through the build to influence both loader generation and manifest structure.

2. Worker File Resolution

When rendering the final manifest, the renderCrxManifest hook extracts the actual service worker filename. For Chrome, it reads manifest.background.service_worker; for Firefox, it uses the first entry in manifest.background.scripts (lines 56-61):

// Lines 56-61
const worker = browser === 'firefox' 
  ? manifest.background?.scripts?.[0]
  : manifest.background?.service_worker

3. Loader String Construction

The plugin generates different loader code depending on the command (serve vs build) and browser. This logic occupies lines 62-90:

  • Development (vite serve): The loader imports @vite/env for import.meta.env support, imports the HMR client virtual module, and conditionally imports your actual worker file from the dev server.
  • Production (vite build): The loader simplifies to a static import './<worker>';.
  • Firefox Special Handling: Because Firefox background pages cannot use ES-module import statements directly, the loader uses dynamic import() syntax.

4. Asset Emission

The generated loader source is emitted as a Vite asset using this.emitFile (lines 96-102):

// Lines 96-102
this.emitFile({
  type: 'asset',
  fileName: getFileName({ type: 'loader', id: 'service-worker' }),
  source: loader
})

The getFileName utility from fileWriter-utilities.ts ensures the file lands at the extension root with a deterministic name like loader-service-worker.js.

5. Manifest Mutation

The plugin patches the manifest object to point to the emitted loader rather than the original source (lines 104-114):

// Chrome path
manifest.background.service_worker = loaderFileName
manifest.background.type = 'module'

// Firefox path
manifest.background.scripts = [loaderFileName]
manifest.background.type = 'module'

6. Final Manifest Return

The modified manifest object returns to the CRXJS pipeline (lines 116-117), completing the compilation step:

// Lines 116-117
return manifest

Why the Loader Pattern Matters

Service workers are scope-restricted to the directory containing their entry file. By emitting a minimal loader at the extension root (the same level as manifest.json), the plugin guarantees the worker can intercept network requests for all extension assets. The loader also centralizes development-only imports like the HMR client, keeping your actual background.ts clean and production-ready.

Working with plugin-background.ts in Your Extension

Configure your Vite project to trigger the automatic service worker compilation:

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

export default defineConfig({
  plugins: [
    crx({ manifest }), // Registers pluginBackground internally
  ],
})

During vite serve, the generated loader imports the virtual HMR client from /${workerClientId} and establishes a WebSocket connection to the dev server. During vite build, the loader becomes a thin static wrapper that immediately imports the bundled worker code.

Summary

  • plugin-background.ts serves as the central orchestrator for service worker compilation in CRXJS, located at packages/vite-plugin/src/node/plugin-background.ts.
  • Virtual HMR injection occurs through a special module resolved in the resolveId hook and loaded via the load hook with injected base URLs.
  • Browser compatibility is handled by detecting Chrome vs Firefox and adjusting both the loader syntax (static vs dynamic imports) and manifest keys (service_worker vs scripts).
  • Scope compliance is achieved by emitting a root-level loader file that imports the actual worker, ensuring the service worker controls the entire extension origin.
  • Manifest mutation happens in renderCrxManifest, where the plugin swaps source paths for the emitted loader filename before returning the final manifest object.

Frequently Asked Questions

Why does CRXJS use a loader file instead of pointing the manifest directly to my background.ts?

The loader file solves two problems simultaneously. First, service workers are scope-restricted to their containing directory, so placing a file at the extension root ensures it can intercept requests for all extension resources. Second, the loader provides a clean separation between development concerns (HMR client, Vite environment shims) and your actual business logic. During production builds, the loader becomes a minimal wrapper that simply imports the bundled worker code.

How does the plugin handle the difference between Chrome and Firefox background scripts?

During the config hook, the plugin stores the target browser from user options (defaulting to Chrome). When generating the loader in renderCrxManifest, it checks this value to determine the correct module syntax: Firefox receives dynamic import() calls because its background pages cannot parse static ES module imports, while Chrome uses standard import statements. The plugin also mutates the correct manifest property—service_worker for Chrome or scripts for Firefox—when patching the background entry point.

What is the virtual HMR client module and when is it loaded?

The virtual module /${workerClientId} is a synthetic module ID registered by the first sub-plugin's resolveId hook. When Vite's module graph requests this ID during vite serve, the load hook returns the workerHmrClient source code with the dev server base URL injected via defineClientValues. This module establishes the WebSocket connection necessary for hot reloading and is never emitted during production builds.

How does service worker compilation differ between development and production modes?

In development, the loader generated by lines 62-90 imports @vite/env to polyfill import.meta.env, imports the HMR client for live reloading, and fetches the actual worker from the dev server URL. In production, the loader simplifies to a single static import statement pointing to the bundled worker file, and the HMR client is excluded entirely. The getFileName utility ensures the production loader receives a cache-friendly filename at the extension root.

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 →