mv3-message-router
v1.0.0
Published
Chrome extension message router — type-safe wrapper over chrome.runtime.sendMessage for Manifest V3 (MV3) service workers, popups, and content scripts. End-to-end typed payloads, zero dependencies.
Maintainers
Readme
mv3-message-router
Chrome extension message router for Manifest V3 (MV3) — a type-safe wrapper over
chrome.runtime.sendMessageconnecting your service worker, popup, options page, and content scripts. Declare messages once, get end-to-end typing everywhere. Zero dependencies.
Type-safe message passing for Chrome / Edge / Firefox MV3 extensions.
Declare your messages once; get end-to-end typing on both send and on
sides. Zero runtime dependencies.
interface Messages {
GET_USER: { input: { id: string }; output: { name: string } };
SAVE_NOTE: { input: { text: string }; output: { ok: boolean } };
}
// service worker
const router = createRouter<Messages>();
router.on("GET_USER", async ({ id }) => ({ name: await db.lookup(id) }));
router.listen();
// popup / content script
const client = createClient<Messages>();
const user = await client.send("GET_USER", { id: "42" }); // typed: { name: string }Why
Vanilla chrome.runtime.sendMessage is untyped, returns unknown, and the
"return true for async response" footgun catches everyone. This package
wraps that protocol behind a small typed API, normalizes error propagation,
and doesn't fight other listeners on the same channel.
Install
pnpm add mv3-message-router
# or: npm i mv3-message-router
# or: yarn add mv3-message-routerUsage
1. Declare your message contract
// src/shared/messages.ts
export interface Messages {
GET_USER: { input: { id: string }; output: { name: string; email: string } };
SAVE_NOTE: { input: { text: string }; output: { ok: boolean } };
CLOSE_TAB: { input: void; output: void };
}2. In the service worker — register handlers
import { createRouter } from "mv3-message-router";
import type { Messages } from "./shared/messages";
const router = createRouter<Messages>();
router.on("GET_USER", async ({ id }, sender) => {
return { name: "Ada", email: "[email protected]" };
});
router.on("SAVE_NOTE", async ({ text }) => {
await chrome.storage.local.set({ note: text });
return { ok: true };
});
router.listen();3. From popup / content / options — send typed messages
import { createClient } from "mv3-message-router";
import type { Messages } from "./shared/messages";
const client = createClient<Messages>();
const user = await client.send("GET_USER", { id: "42" });
// ^? { name: string; email: string }
await client.sendToTab(tabId, "CLOSE_TAB", undefined);Error handling
Handler exceptions are serialized and re-thrown on the caller as
MessageRouterError, with the original name, message, and stack
preserved on .cause:
import { MessageRouterError } from "mv3-message-router";
try {
await client.send("SAVE_NOTE", { text: "..." });
} catch (e) {
if (e instanceof MessageRouterError) {
console.error(e.cause?.stack);
}
}API
createRouter<M>() → Router<M>
router.on(type, handler)— register a handler. Returns an unsubscribe fn.router.off(type)— remove a handler.router.listen()— attach tochrome.runtime.onMessage. Returns a detach fn.
Handlers receive (payload, sender) and may return a value or a Promise.
createClient<M>() → Client<M>
client.send(type, payload)— round-trip to the SW. Returns the typed output.client.sendToTab(tabId, type, payload)— round-trip to a content script.
MessageRouterError
Thrown on the caller when a handler rejects. Original error info on .cause.
Plays well with other listeners
The router only handles messages it sent (envelopes are tagged with an
internal marker). Messages from third-party libraries pass straight through
to your other chrome.runtime.onMessage listeners.
Demo extension
A runnable demo lives in example/. It loads as an unpacked
Chrome extension that exercises typed messaging across three contexts:
popup → service worker, and content script → service worker, sharing a
single counter.
cd example
pnpm install
pnpm build
# then load example/dist/ via chrome://extensions → "Load unpacked"See example/README.md for full instructions.
Related packages
Part of a small MV3 toolkit for Chrome / Edge / Firefox extensions by @graybearo:
mv3-keepalive— service-worker keepalive + durablechrome.alarmsmv3-content-bridge— content-script ↔ page-context typed bridgemv3-storage— typedchrome.storagewrappermv3-wait-for-element— MutationObserver-backedwaitForElementfor content scriptschrome-extension-vite-react— MV3 starter (Vite + React + TS) that uses this packagechrome-extension-vite-svelte— MV3 starter (Vite + Svelte + TS)chrome-extension-webpack-react— MV3 starter (webpack + React + TS)chrome-extension-side-panel— Side Panel API starter (Chrome 114+)webpack-ext-reloader-next— auto-reload for webpack-based MV3 extensionsawesome-mv3— curated list of MV3 tools, libraries, and resources
License
MIT — see LICENSE.
