til · daniel metzner

One extension codebase for Chrome and Firefox — signing is the only real fork

We needed a header-injection extension at work for feature-flag testing. Every option in the stores wanted an account, hid the basics behind a paywall, or — like ModHeader, the one everyone reaches for — now runs ads and nudges you toward a subscription inside what is, functionally, a devtool. I’d assumed that friction bought something: that setting a request header was fiddly enough to be worth paying for. Then I looked at what it actually takes.

Manifest V3 hands you the whole mechanism. declarativeNetRequest sets request headers as a single dynamic rule with a modifyHeaders action; a few dozen lines do the work and everything else is just UI. A weekend later we had Overhead — a small, public, MIT-licensed MV3 devtool that injects request headers, no account, no ads, with one convenience the paid ones lack: it imports the known feature-flag headers of our systems straight from an endpoint or a JSON file, so nobody has to guess header names.

Then I budgeted real time for “the Firefox port”. There wasn’t one. The same folder loads unpacked in Chrome and as a temporary add-on in Firefox, unchanged.

Three tricks carry it. Every API call goes through globalThis.browser ?? globalThis.chrome, which picks the promise-based WebExtension namespace on whichever browser is running. The manifest declares the background script under both keys — Chromium reads service_worker, Firefox reads scripts, and each silently ignores the one it doesn’t understand:

"background": {
  "service_worker": "sw.js",
  "scripts": ["sw.js"],
  "type": "module"
}

And the manifest sets browser_specific_settings.gecko.id — for MV3, AMO no longer assigns an ID at submission, and storage.sync won’t work without one.

Where the browsers genuinely fork is distribution. For a self-distributed tool you don’t need a public store listing on either side, and here the reputations are backwards. Chrome has no signing step at all — but also effectively no self-distribution path: load-unpacked is it (persistent, but with a developer-mode nag), unless you control enterprise policies. Firefox requires signing even for self-distributed add-ons, which sounds like bureaucracy — except the unlisted channel is fully automated. Automated validation, immediate signature, no human review, no store page. One CI step:

npx web-ext sign --channel=unlisted \
  --api-key "$AMO_JWT_ISSUER" --api-secret "$AMO_JWT_SECRET"

The API credentials come from a form on AMO. We wired this into a GitHub Actions workflow on version tags; a signed .xpi lands on the release a couple of minutes later, and anyone on the team drags it into Firefox for an install that survives restarts. So the browser famous for strict signing ends up with the smoother permanent-install story for self-distributed tools.

The catch: an unlisted .xpi doesn’t auto-update — that needs an update_url in the manifest plus a self-hosted updates.json, which we haven’t bothered with yet. And signing is forever-ish: each version number can only be signed once per channel, so a botched upload means bumping the version.

Rule of thumb: write against browser ?? chrome, declare the background script twice, set a gecko ID from day one — and stop treating Firefox signing as a reason to ship Chrome-only.

Follow-up resources

← all notes