How the Custom HMR Plugin (@extension/hmr) Enables Hot Reload for Chrome Extensions
The @extension/hmr package provides a lightweight WebSocket-based Hot Module Replacement system that automatically reloads Chrome extension background scripts, content scripts, and UI pages the moment source files change, eliminating manual refreshes during development.
The jonghakseo/chrome-extension-boilerplate-react-vite repository ships with this purpose-built HMR solution to overcome Chrome's unique extension constraints. Unlike standard Vite HMR designed for traditional web apps, this custom plugin handles Manifest V3 service workers and aggressively cached content scripts through a dedicated WebSocket dev server and targeted runtime injections.
Architecture of the @extension/hmr System
The custom HMR implementation consists of three coordinated layers that bridge Vite's build pipeline with the Chrome extension runtime.
WebSocket Communication Layer
At the core of the system is a lightweight bidirectional messaging layer defined in packages/hmr/lib/consts.ts. The WebSocket server runs alongside the Vite dev server, while client sockets live inside the extension bundles.
watch-rebuild-plugin.ts(lines 48‑59): Implements thecloseBundleVite hook to open a socket toLOCAL_RELOAD_SOCKET_URLand broadcast aBUILD_COMPLETEmessage containing a unique HMR ID.init-client.ts: Opens a persistent WebSocket connection from within the extension runtime, listens forDO_UPDATEcommands, executes the registered update handler, and acknowledges withDONE_UPDATE.
Runtime Injection Scripts
Because Chrome extensions run in isolated contexts (background service workers, content scripts, popup pages), the system injects specialized reload logic rather than relying on Vite's standard module replacement.
reload.ts: Callschrome.runtime.reload()to restart the entire extension. Ideal for background scripts and service workers.refresh.ts: Performswindow.location.reload()on the active tab, deferring execution while the tab is hidden to prevent data loss. Used for content scripts and UI pages.
Both modules import initClient and register an onUpdate callback that triggers the appropriate reload mechanism when the WebSocket signals an update.
Vite Plugin Orchestration
Three custom plugins wire the build pipeline to the HMR runtime:
watchRebuildPlugin: Injects the selected injection script (reload.jsorrefresh.js) into every bundle via a wrapper (lines 61‑65 ofwatch-rebuild-plugin.ts). Accepts areload: booleanoption to choose between extension reload and page refresh.watchPublicPlugin: Adds every file underpublic/to Vite's watch list, ensuring static assets like icons or HTML files trigger rebuilds and subsequent hot reloads.makeEntryPointPlugin: Generates an additional_dev.jsentry point for each content script and rewrites the generated chunk to import that file. This bypasses Chrome's aggressive content-script caching and supports Firefox through anIS_FIREFOXpath check.
Step-by-Step Hot Reload Flow
The custom HMR plugin coordinates the following sequence every time you save a file:
- Development Build Starts: When
viteruns withIS_DEVenabled,chrome-extension/vite.config.mtsregisters the HMR plugins:
// chrome-extension/vite.config.mts
import { watchRebuildPlugin, watchPublicPlugin } from '@extension/hmr';
// ...
plugins: [
libAssetsPlugin({ outputPath: outDir }),
watchPublicPlugin(),
makeManifestPlugin({ outDir }),
IS_DEV && watchRebuildPlugin({ reload: true, id: 'chrome-extension-hmr' }),
],
-
Build Completion Signals Server: Upon finishing the bundle,
watchRebuildPlugin'scloseBundlehook sends aBUILD_COMPLETEmessage to the locally running reload server (started viapnpm run dev), including the unique pluginid. -
Server Broadcasts Update: The reload server receives the message and forwards a
DO_UPDATEcommand to all connected clients listening for that specific ID, preventing cross-talk between multiple independent builds (e.g., side-panel or devtools pages). -
Extension Runtime Handles Update: Each bundle contains the wrapper code injected at lines 61‑65 of
watch-rebuild-plugin.ts, which loads the appropriate injection script (reload.tsorrefresh.ts). The injection script initializes the client withinitClient({ id, onUpdate }), opens its WebSocket, and awaits the signal. -
Instant Reload Executes: When
DO_UPDATEarrives, the callback invokes eitherchrome.runtime.reload()for the background orwindow.location.reload()for content scripts, delivering the updated code without manual intervention inchrome://extensions.
Configuring HMR in Extension Builds
Enabling Extension Reload for Background Scripts
Set reload: true to inject the reload.ts script, which restarts the entire extension when any file changes:
// chrome-extension/vite.config.mts
import { defineConfig } from 'vite';
import { watchRebuildPlugin, watchPublicPlugin } from '@extension/hmr';
import makeManifestPlugin from './utils/plugins/make-manifest-plugin.js';
import env, { IS_DEV } from '@extension/env';
export default defineConfig({
plugins: [
watchPublicPlugin(),
makeManifestPlugin({ outDir }),
IS_DEV && watchRebuildPlugin({ reload: true, id: 'chrome-extension-hmr' }),
],
});
Enabling Page Refresh for Content Scripts
For content scripts that need to refresh the host page rather than the extension, set reload: false:
// pages/content/build.mts
import { defineConfig } from 'vite';
import { watchRebuildPlugin } from '@extension/hmr';
export default defineConfig({
plugins: [
watchRebuildPlugin({ reload: false, id: 'content-script-hmr' }),
],
});
Manual Client Initialization (Advanced)
For rare cases requiring custom update logic, import initClient directly:
// Inside any extension script
import initClient from '@extension/hmr/lib/initializers/init-client.js';
initClient({
id: 'my-custom-context',
onUpdate: () => {
console.log('Code updated');
window.location.reload();
},
});
Key Source Files and Their Roles
packages/hmr/lib/consts.ts: DefinesLOCAL_RELOAD_SOCKET_URLand message type constants (BUILD_COMPLETE,DO_UPDATE,DONE_UPDATE).packages/hmr/lib/plugins/watch-rebuild-plugin.ts: Core Vite plugin that injects HMR wrappers (lines 61‑65) and signals the reload server viacloseBundle(lines 48‑59).packages/hmr/lib/plugins/watch-public-plugin.ts: Watches thepublic/directory for static asset changes.packages/hmr/lib/plugins/make-entry-point-plugin.ts: Creates_dev.jsentry points for content script cache busting.packages/hmr/lib/injections/reload.ts: Triggers full extension reload viachrome.runtime.reload().packages/hmr/lib/injections/refresh.ts: Triggers page reload with visibility deferral.packages/hmr/lib/initializers/init-client.ts: Establishes the WebSocket connection inside the extension runtime and routes update commands.
Summary
- The @extension/hmr package replaces Vite's native HMR with a WebSocket-based system tailored for Chrome extension architecture.
watchRebuildPlugincoordinates builds by sendingBUILD_COMPLETEsignals and injecting runtime handlers into every bundle.- Injection scripts (
reload.tsandrefresh.ts) handle the distinct requirements of service workers versus content scripts. makeEntryPointPluginsolves content-script caching by generating dynamic_dev.jsentry points.- The system supports multiple concurrent builds through unique HMR IDs, enabling simultaneous development of background, popup, and side-panel contexts.
Frequently Asked Questions
How does this custom HMR system differ from Vite's native HMR?
Standard Vite HMR replaces modules in a running browser tab using ES module imports, but Chrome extensions run in isolated contexts with persistent service workers and cached content scripts. The custom HMR plugin uses WebSockets to trigger full chrome.runtime.reload() or page refreshes instead of in-place module replacement, ensuring Manifest V3 compliance and bypassing Chrome's aggressive script caching.
Why does the system use WebSockets instead of Chrome's native messaging?
WebSockets provide a bidirectional communication channel between the Node.js dev server and the extension runtime that works uniformly across background scripts, content scripts, and UI pages. Chrome's chrome.runtime.sendMessage API requires complex port management between contexts and fails when the background service worker restarts, whereas the WebSocket connection in init-client.ts re-establishes automatically and handles the DO_UPDATE signal reliably.
How does the plugin handle cache-busting for content scripts?
The makeEntryPointPlugin generates a secondary _dev.js file for every content script entry point and rewrites the main bundle to import that file. Because the _dev.js path changes dynamically with each build, Chrome treats it as a new resource, forcing a fetch of the latest code rather than serving the cached version. Firefox support is handled through a conditional IS_FIREFOX path check within the same plugin.
Can I use different reload strategies for background and content scripts?
Yes. The reload option in watchRebuildPlugin controls the injection strategy per build. Set reload: true for background scripts to trigger chrome.runtime.reload(), and reload: false for content scripts to trigger page refreshes via refresh.ts. Each build can also specify a unique id parameter to ensure background updates do not trigger content script refreshes and vice versa.
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 →