npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

rsbuild-plugin-web-extension-hmr

v0.3.0

Published

Rsbuild plugin for Chrome MV3 extensions: manifest-driven multi-environment builds with HMR for pages, background, and content scripts, including hot re-injection that works on strict-CSP pages.

Readme

rsbuild-plugin-web-extension-hmr

An Rsbuild plugin for building Chrome MV3 extensions with working HMR in every extension context: pages, the background service worker, and content scripts. It derives your build configuration from a TypeScript manifest and adds content-script hot re-injection that survives strict-CSP pages (GitHub and the like) and updates the running script without reloading the page.

Two facts to know up front. Content-script HMR requires the manifest to declare a background service worker, because the background hosts the WebSocket runtime that transports updates; a content-only extension gets no update channel. And state preservation across updates is opt-in, but the plugin owns the mechanics: register save, restore and teardown callbacks with registerHmrState and the plugin does the signal listening, the persistence and the teardown ordering for you. See State preservation below, and examples/react/src/content/layover.ts in the repository for the reference implementation.

Install

pnpm add -D rsbuild-plugin-web-extension-hmr @rsbuild/core

@rsbuild/core is a peer dependency, so install it alongside the plugin.

Usage

Add the plugin to your rsbuild.config.ts and pass it your manifest:

import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { pluginExtension } from "rsbuild-plugin-web-extension-hmr";
import manifest from "./src/manifest";

export default defineConfig({
  plugins: [
    pluginReact(),
    pluginExtension({
      manifest,
      hmr: {
        // Internal HMR coordination WebSocket. The plugin proxies it through
        // the dev server at /hmr-extension, so only server.port needs exposing.
        port: 3981,
        contentScriptHmr: true,
        // Hot re-injection for content scripts: changes are injected into the
        // page via chrome.scripting.executeScript without a page reload.
        hotReinjection: true,
      },
    }),
  ],
  dev: {
    // Write files to disk so Chrome can load the extension from the output
    // directory while the dev server runs.
    writeToDisk: true,
    // Disable live reload so a change never forces a full page refresh; the
    // plugin drives updates through HMR instead.
    liveReload: false,
  },
  server: {
    // Single source of truth for the dev-server port. The plugin bakes it into
    // the extension's WebSocket URL and HMR client, injects the /hmr-extension
    // proxy, and sets strictPort so an occupied port fails fast instead of
    // silently auto-incrementing away from the baked URLs.
    port: 3980,
  },
});

pluginReact is only needed if your extension pages use React. The rest of the configuration applies to any MV3 extension.

For a complete working project, see examples/react in the repository. It wires the plugin to a popup, a background service worker, and content scripts in both the ISOLATED and MAIN worlds.

Options

pluginExtension takes a single options object.

manifest (required)

Your Manifest V3 definition, typed as chrome.runtime.ManifestV3. Declare only what production needs. The plugin adds dev-only entries to development builds, such as the <all_urls> host permission that programmatic injection requires, so your production manifest stays clean.

entryNames

Explicit entry names keyed by manifest source path, for the cases where the derived name is wrong. By default a content script is named after its filename stem, except that an index file takes its parent directory's name instead, so ./src/content/layover/index.tsx becomes layover. Keys match with or without a leading ./, and the name decides the production output path, so escapeRelay below is emitted as content/escapeRelay.js.

pluginExtension({
  manifest,
  entryNames: { "./src/content/escape-relay.ts": "escapeRelay" },
});

hmr.port

Port for the internal HMR coordination WebSocket server. Default 8888. The plugin proxies this through the dev server at /hmr-extension, so this port does not need to be exposed to the browser.

hmr.contentScriptHmr

Enable the content-script HMR runtime. Default true. With this enabled and hotReinjection disabled, a content change triggers an extension reload followed by a refresh of the tracked tabs. With this disabled, the runtime is not injected at all, so tabs are never registered and are not refreshed after extension reloads.

hmr.hotReinjection

Enable hot re-injection for content scripts. Default false. When enabled, a change causes the background to inject the fresh bundle from disk via chrome.scripting.executeScript into each entry's declared world, ISOLATED by default, with no page reload and no dependence on the page's CSP. When disabled, content changes fall back to an extension reload plus a refresh of the tracked tabs; a script that persisted its state on the plugin's save signal can restore it after the refresh.

dev.host

Host for the dev-server HMR client. Default "localhost".

dev.port

Dev-server port. Default 3000. Prefer setting rsbuild's server.port instead, because the plugin reads that value and keeps every generated artifact in sync. If both dev.port and server.port are set they must agree, or startup throws.

How the HMR behaves

  • Per-context HMR. Extension pages (popup, options, devtools) use rsbuild's built-in HMR with React Fast Refresh. The background service worker and content scripts are coordinated over a custom WebSocket that the plugin proxies through the dev server.
  • Hot re-injection on strict-CSP pages. With hotReinjection: true, content scripts update in place through chrome.scripting.executeScript. Because the injection is extension-mediated, it is exempt from the page's Content-Security-Policy and works on strict-CSP sites, with no page reload. A script that registers its state, as the example does, gets it back when the fresh bundle runs.
  • Config-change detection. With hotReinjection enabled, the background compares a hash of the generated bridge config on connect and reloads the extension only when that config actually changed, so restarting the dev server with an unchanged config leaves the extension undisturbed. Without hot re-injection there are no generated bridges to hash, and this check is inactive.
  • Tab recovery after extension reloads. Before any chrome.runtime.reload(), for example on a background edit, registered tabs receive a save signal and are refreshed afterwards, so their content scripts re-register and keep receiving updates.
  • Production verification. A post-build check scans production bundles for dev-only code, such as WebSocket connections or hot-update references, and warns when it finds any; it does not fail the build. Check the build log for the verification result.

State preservation

A content script that survives an update has to answer two questions only the application can answer: what to save, and how to come back from it. Everything around those answers is the same for every consumer, so the plugin owns it. Register your callbacks from the ./runtime subpath:

import { registerHmrState } from "rsbuild-plugin-web-extension-hmr/runtime";

const layoverHmrState = registerHmrState(
  "layover-demo",
  {
    save: () => ({ counter, panelRight, panelBottom }),
    restore: (savedState) => applyRestoredState(savedState),
    teardown: () => teardownOverlay(),
  },
  { entry: "layover" }
);

Register before you build any DOM, so a restore shapes the first render. The returned handle also exposes save() for persisting eagerly at a mutation site and unregister() for dropping the registration without running teardown.

The plugin persists each registration to sessionStorage under __rsbuild_ext_hmr_state__:<stateKey> and reacts to two signals: the background sends a save request before every extension reload, and an update signal before a fresh bundle is injected for an entry. Both arrive on two channels, over chrome.runtime for ISOLATED-world scripts and over a window message for MAIN world. Only the update signal carries a compilation hash, and that hash is what lets the second delivery be recognized and ignored instead of tearing down the registration the fresh bundle just made. The save signal carries no hash and needs none, because saving twice is harmless.

Teardown runs before a fresh bundle replaces an instance in place, and its ordering differs by world. An ISOLATED-world registration is torn down synchronously by the bridge before it asks for the fresh bundle, so cleanup is guaranteed to finish first. MAIN world cannot be reached synchronously from the bridge, so it gets the window message and the 50 ms head start before injection, which is best effort rather than a guarantee.

An extension reload takes a different route. It ends in a tab refresh, so the page carries the old instance away by itself and the plugin only asks for a save beforehand; expect no teardown call on that path.

Two options refine the registration. entry names the content-script entry the registration belongs to, so an update for a different entry leaves it alone; leave it unset in single-entry worlds. stalenessMs sets how old a snapshot may be before it is discarded instead of restored, defaulting to five minutes.

Production builds resolve rsbuild-plugin-web-extension-hmr/runtime to a no-op, so consumers call registerHmrState unconditionally with no environment gating of their own, and none of the machinery ships. The post-build verification pass flags any bundle that still contains it.

examples/react/src/content/layover.ts in the repository is the reference implementation, and pageProbe.ts beside it is the MAIN-world counterpart.

Requirements

  • @rsbuild/core ^1.5.0 or ^2.0.0
  • Chrome, Manifest V3
  • Node.js >= 18 with Rsbuild 1.x; Rsbuild 2.x requires ^20.19.0 || >=22.12.0
  • A background service worker declared in the manifest, required for content-script HMR: the background hosts the WebSocket runtime that transports updates, so a content-only extension has no update channel

@types/chrome

The plugin's public API exposes chrome.runtime.ManifestV3 in its options, so @types/chrome ships as a runtime dependency. That means chrome.runtime.ManifestV3 resolves in your manifest without your project installing @types/chrome separately.

If your project pins its own @types/chrome, two ambient chrome declaration sets are present. They conflict only when skipLibCheck is false, which is uncommon, so version skew between the two copies is usually harmless.