Shipping One Manifest for Chrome and Firefox
Generate per-engine manifests from a single source: background service_worker vs scripts, browser_specific_settings, key differences, and a build step that fails on drift.
Table of Contents
- Root cause: the engines disagree on a handful of keys, not on the file
- Step 1. Write the shared manifest as data, not JSON
- Step 2. Express each engine as an overlay
- Step 3. Emit and validate in one step
- Step 4. Keep the code single-source too
- Step 5. Lint each artefact with the engine’s own tool
- Cross-browser variation
- Verification
- FAQ
- Related
Two manifests in the repository start identical and diverge within three releases: a permission is added to one, a content script glob is fixed in the other, and the Firefox build ships with a rule nobody remembers removing. The manifest is configuration, and duplicated configuration always drifts. This page is part of Cross-Browser API Compatibility and builds one source of truth that emits a correct manifest for each engine, with a check that fails the build when the two go out of step for a reason nobody declared.
Root cause: the engines disagree on a handful of keys, not on the file
Ninety percent of a Manifest V3 file is identical across Chrome, Firefox and Safari. The disagreements are concentrated in five places, and every one of them is a key that either must differ or must be absent.
Step 1. Write the shared manifest as data, not JSON
A JavaScript module can be composed and checked; a JSON file can only be copied.
1// build/manifest.base.js
2export const base = {
3 manifest_version: 3,
4 name: "__MSG_extensionName__",
5 description: "__MSG_extensionDescription__",
6 default_locale: "en",
7 version: process.env.npm_package_version,
8 icons: { 16: "icons/16.png", 48: "icons/48.png", 128: "icons/128.png" },
9 action: { default_popup: "popup.html", default_title: "__MSG_actionTitle__" },
10 options_ui: { page: "options.html", open_in_tab: true },
11 permissions: ["storage", "scripting", "tabs", "alarms"],
12 host_permissions: ["https://*/*"],
13 content_scripts: [{ matches: ["https://*/*"], js: ["content.js"], run_at: "document_idle" }],
14};
Execution context: Node, at build time — never shipped. Reading the version from npm_package_version removes the second-most-common drift after permissions: a version bumped in package.json and forgotten in the manifest. Everything here is accepted verbatim by all three engines, which is the test for whether a key belongs in the base rather than in an overlay.
Step 2. Express each engine as an overlay
1// build/manifest.targets.js
2export const targets = {
3 chrome: (m) => ({
4 ...m,
5 background: { service_worker: "sw.js", type: "module" },
6 side_panel: { default_path: "panel.html" },
7 minimum_chrome_version: "116",
8 permissions: [...m.permissions, "sidePanel"],
9 }),
10
11 firefox: (m) => ({
12 ...m,
13 background: { scripts: ["sw.js"], type: "module" }, // Firefox MV3 uses an event page
14 sidebar_action: { default_panel: "panel.html", default_title: "__MSG_panelTitle__" },
15 browser_specific_settings: {
16 gecko: { id: "tab-curator@example.com", strict_min_version: "121.0" },
17 },
18 }),
19
20 safari: (m) => ({
21 ...m,
22 background: { service_worker: "sw.js", type: "module" },
23 // No side panel surface; the popup carries the same content.
24 }),
25};
Execution context: Node, at build time. The background declaration is the one that catches everyone: Firefox’s Manifest V3 implementation uses a non-persistent event page declared with scripts, not a service_worker key, and a manifest with the Chrome form is rejected at install with a validation error rather than a warning. Requesting the sidePanel permission only in the Chrome overlay avoids an unknown-permission warning in Firefox’s linter. Safari accepts the Chrome shape but has no side panel surface at all, so the overlay simply omits it.
Step 3. Emit and validate in one step
1// build/manifest.mjs
2import { writeFileSync, mkdirSync } from "node:fs";
3import { base } from "./manifest.base.js";
4import { targets } from "./manifest.targets.js";
5
6// Keys that are ALLOWED to differ. Anything else diverging is a mistake.
7const ALLOWED = new Set([
8 "background", "side_panel", "sidebar_action",
9 "browser_specific_settings", "minimum_chrome_version", "permissions", "key",
10]);
11
12const built = Object.fromEntries(
13 Object.entries(targets).map(([name, overlay]) => [name, overlay(structuredClone(base))]),
14);
15
16const [reference, ...others] = Object.values(built);
17for (const other of others) {
18 for (const key of new Set([...Object.keys(reference), ...Object.keys(other)])) {
19 if (ALLOWED.has(key)) continue;
20 if (JSON.stringify(reference[key]) !== JSON.stringify(other[key])) {
21 throw new Error(`manifest drift on "${key}" — add it to ALLOWED or fix the overlay`);
22 }
23 }
24}
25
26for (const [name, manifest] of Object.entries(built)) {
27 mkdirSync(`dist/${name}`, { recursive: true });
28 writeFileSync(`dist/${name}/manifest.json`, JSON.stringify(manifest, null, 2) + "\n");
29}
Execution context: Node, invoked from an npm script before packaging. The ALLOWED set is the point of the whole exercise: it turns “these files differ” from an invisible fact into a declaration somebody had to write down. A new permission added to one overlay by accident fails the build with the key name in the message. Wire this into the release pipeline described in building a GitHub Actions pipeline for extensions so it runs before anything is uploaded.
Step 4. Keep the code single-source too
A generated manifest is only half the job if sw.js still branches on the engine. Let the capability probe do it.
1// sw.js — the same file ships to all three builds
2import { can } from "./capabilities.js";
3
4chrome.action.onClicked.addListener(async (tab) => {
5 if (can.sidePanel) return chrome.sidePanel.open({ tabId: tab.id });
6 if (typeof browser !== "undefined" && browser.sidebarAction) return browser.sidebarAction.open();
7 return chrome.windows.create({ url: "/panel.html", type: "popup", width: 420, height: 640 });
8});
Execution context: Service worker in Chrome and Safari, event page in Firefox — the same file works in both because the difference is in how the browser loads it, not in what it contains. Registering the listener at the top level is required in all three. The probe pattern is covered in feature detection instead of browser sniffing, and using it here means one bundle rather than three.
Step 5. Lint each artefact with the engine’s own tool
Each store runs a validator you can run first.
1# Firefox: the official linter, the same one AMO runs on upload
2npx web-ext lint --source-dir dist/firefox
3
4# Chrome: pack and check the manifest parses with the target minimum version
5npx chrome-webstore-upload-cli@3 --source dist/chrome --dry-run
6
7# Safari: the converter reports unsupported keys as warnings
8xcrun safari-web-extension-converter dist/safari --no-open --force
Execution context: A developer machine or CI runner. web-ext lint catches unknown permissions, an invalid gecko.id and the service_worker-instead-of-scripts mistake before an upload is rejected. The Safari converter is macOS-only, so gate that line behind a platform check in CI; what it reports is covered in handling Safari web extension conversion gaps.
Cross-browser variation
- Chrome/Edge:
service_workerwithtype: "module"is the only accepted background form.browser_specific_settingsmust not be present — Chrome rejects unknown top-level keys during store review even though it tolerates them locally. - Firefox: Manifest V3 background is
scriptswith an optionaltype: "module".browser_specific_settings.gecko.idis required for AMO signing, andstrict_min_versioncontrols which users receive the build. - Safari: accepts the Chrome-shaped manifest. Version floors come from the containing app’s deployment target rather than a manifest key.
- All engines: the
versionstring must be one to four dot-separated integers. Useversion_namefor anything human-readable such as a beta suffix.
Verification
- Run the build and diff
dist/chrome/manifest.jsonagainstdist/firefox/manifest.json. Every difference should correspond to a key inALLOWED. - Add a permission to one overlay only and confirm the build fails with that key named.
- Load
dist/firefoxwithweb-ext runand confirm the event page starts. - Load
dist/chromeunpacked and confirm the side panel opens from the action button. - Bump the version in
package.jsonalone and confirm all three manifests pick it up.
FAQ
Can I just use one manifest with extra keys and let each engine ignore what it does not know?
Locally, yes. In review, no — both Chrome and AMO flag unknown keys, and Firefox rejects the service_worker background outright.
Does Firefox support service workers as the background context yet?
Its Manifest V3 background is a non-persistent event page. Write your code as if it can be evicted at any moment either way, which is the assumption the service worker fundamentals guide starts from.
Should the key field be in the shipped Chrome manifest?
No. It pins the extension ID for local development so OAuth redirect URLs stay stable; the store assigns the ID itself. Add it in a dev-only overlay.
What about three separate bundles of code?
Avoid it. One bundle plus capability probes is smaller to maintain and means a bug fixed once is fixed everywhere.
Related
- Feature detection instead of browser sniffing — the runtime half of one-source shipping.
- Handling Safari web extension conversion gaps — the third artefact and what its converter complains about.
- Publishing to Firefox add-ons and Safari — where each artefact goes next.
- Cross-Browser API Compatibility — the guide this page belongs to.