Skip to content

Cross-browser builds

ExtForge produces a separate output directory for each declared browser. The same source compiles to dist/chrome/, dist/firefox/, dist/edge/, and dist/safari/ — each with its own manifest.json generated from your shared config plus per-browser overrides.


In extforge.config.ts:

import { defineConfig } from 'extforge';
export default defineConfig({
browsers: ['chrome', 'firefox', 'edge', 'safari'],
});

extforge build iterates this list. extforge dev defaults to chrome; use --browser firefox to run the watcher against a different target.

Valid values: chrome, firefox, edge, safari. Duplicates are deduplicated.


Use manifest.browserOverrides to merge browser-specific values into the generated manifest. Every top-level manifest key can be overridden — name, version, description, permissions, action, background, contentScripts, sidePanel, commands, firefoxId, etc.

Nested objects (permissions, action, background, sidePanel, commands) are shallow-merged so a partial override doesn’t drop unrelated fields. Arrays (contentScripts, webAccessibleResources) and primitives are replaced wholesale.

manifest: {
background: {
entrypoint: 'src/background/index.ts',
},
browserOverrides: {
firefox: {
firefoxId: 'my-extension@example.com',
},
safari: {
// safari-specific keys here
},
},
}

Why Firefox uses scripts and Chrome uses service_worker

Section titled “Why Firefox uses scripts and Chrome uses service_worker”

Manifest V3 specifies background.service_worker for Chrome. Firefox MV3 (109+) supports both background.service_worker and background.scripts. ExtForge writes the correct key per-browser automatically:

BrowserBackground manifest key
Chromebackground: { service_worker: "..." }
Firefoxbackground: { scripts: ["..."] }
Edgebackground: { service_worker: "..." }
Safaribackground: { service_worker: "..." }

You declare a single background.entrypoint in config. The manifest generator reads the browser capability matrix and outputs the appropriate key. No override needed.

If you need Firefox to use service_worker explicitly (not recommended), you can override via browserOverrides.firefox.background.


ExtForge walks the configured src/ directory recursively (with the usual ignores: node_modules, dist, .git, coverage, .cache) and scans every TS/JS source for chrome.* and browser.* API calls — including helper modules imported from entries, not just the entries themselves. Optional chaining (chrome?.foo.bar) is matched too. Calls are cross-referenced against a committed slice of MDN browser-compat-data (at src/core/compat/data.json). CI fails when the snapshot is more than 90 days stale; regenerate with pnpm compat:rebuild and commit the diff.

The scanner is regex-driven but smart enough to ignore chrome.* tokens that appear inside comments, string literals, template-literal bodies, and regex literals (/chrome\.tabGroups/) so you won’t see false positives from documentation strings or test fixtures.

By default, compat issues are warnings. Pass --strict to the build command to treat them as errors:

Terminal window
extforge build --strict

In extforge.config.ts you can make strict mode permanent for dev:

export default defineConfig({
dev: { strictCompat: true },
});

Suppress a single compat warning with a comment immediately above the call:

// extforge-ignore-compat: Chrome-only; Firefox uses the popup fallback
chrome.sidePanel.open({ tabId });

The reason string (after the colon) is required. A bare // extforge-ignore-compat without a reason is rejected — the checker logs a warning and does not suppress.

The suppression applies to the next non-blank, non-comment line. Blank lines and other comments between the suppression and the call are skipped.


chrome.tabGroups is Chrome/Edge only. Safari does not implement it.

// Wrong — breaks on Safari
chrome.tabGroups.query({}, (groups) => { /* ... */ });
// Right — guard or drop Safari from browsers[]
if (typeof chrome.tabGroups !== 'undefined') {
chrome.tabGroups.query({}, (groups) => { /* ... */ });
}

Or, if you need tab groups, drop Safari from your browsers list:

export default defineConfig({
browsers: ['chrome', 'edge'], // no safari
});

chrome.sidePanel.* — Chrome and Edge only

Section titled “chrome.sidePanel.* — Chrome and Edge only”

chrome.sidePanel is not available on Firefox or Safari. Provide a fallback popup for those browsers.

// extforge-ignore-compat: Chrome/Edge only; Firefox gets a popup via browserOverrides
chrome.sidePanel.open({ tabId: tab.id! });

In config, provide a Firefox fallback:

manifest: {
sidePanel: {
defaultPath: 'src/ui/sidepanel/sidepanel.html',
},
browserOverrides: {
firefox: {
// Firefox sees a popup instead
action: {
defaultPopup: 'src/ui/sidepanel/sidepanel.html',
defaultTitle: 'Open panel',
},
},
},
}

chrome.declarativeNetRequest.* — partial on Firefox and Safari

Section titled “chrome.declarativeNetRequest.* — partial on Firefox and Safari”

declarativeNetRequest is in MV3 for Chrome, Edge, and Firefox, but the exact API surface differs. Safari support is partial. Check src/core/compat/data.json for the specific method coverage. Gate on typeof chrome.declarativeNetRequest !== 'undefined' or audit with extforge build --strict.

As noted above, ExtForge handles the service_worker / scripts split automatically. You only need a browserOverrides entry if you want to supply different background entry points per-browser, not just different manifest keys.


After extforge build, the output structure is:

dist/
chrome/
manifest.json
background.js
popup.html
...
firefox/
manifest.json
background.js
popup.html
...
edge/
manifest.json
...
safari/
manifest.json
...

Load the extension from dist/chrome/ in Chrome’s extension manager, dist/firefox/ in Firefox, and so on.

In CI, build all browsers in one command:

Terminal window
extforge build
# or build a single browser:
extforge build --browser firefox

See deployment for packaging and store submission.

See EXT_COMPAT_UNSUPPORTED for the error reference when strict mode is on.