# Browser launch flags for Chrome, Firefox, and Edge
Source: https://extension.js.org/docs/browsers/browser-flags
Use firefox://flags, chrome://flags, and browser launch flags in Extension.js. Control Chrome, Firefox, and Edge runtime behavior during extension development.
Control browser launch behavior for debugging, automation, and runtime
experiments.
**Looking for `chrome://flags` or `edge://flags`?** Type it into your address
bar, which is the browser's built-in page. **Firefox has no `firefox://flags`**;
use `about:config` instead.
If you're toggling flags to **test or build a browser extension**, Extension.js
applies them per-project automatically across Chrome, Edge, and Firefox, so you
don't pass flags by hand on every run. [Create your first extension in 30
seconds →](/docs/getting-started)
Tune browser launch behavior without changing extension source code. Extension.js merges browser flags from your `extension.config.*` and applies them in `dev`, `preview`, and `start` flows.
## Does Firefox have `firefox://flags`?
Firefox does not use `firefox://flags` the way Chromium browsers use `chrome://flags` or `edge://flags`. Firefox exposes runtime toggles through `about:config` (preferences) and accepts launch flags from the command line.
If you searched for `firefox://flags`, `firefox //flags`, `browser://flags`, `mozilla://flags`, or `about flags firefox`, you are probably trying to change browser behavior during extension development. In Extension.js, do that in two places:
* Use [`browserFlags`](/docs/browsers/browser-flags) for launch-time flags Extension.js passes to the browser binary.
* Use [Firefox preferences](/docs/browsers/browser-preferences) for repeatable Gecko runtime behavior that would otherwise live in `about:config`.
Both work for Chrome, Edge, and Firefox extension development from the same Extension.js project.
## Flags in other browsers
Extension.js launches and configures any Chromium-based browser the same way.
* **`brave://flags`, `opera://flags`, `vivaldi://flags`, `yandex://flags`**: all
Chromium-based, so they behave identically to `chrome://flags`, and Extension.js
manages them per-project.
* **`internet://flags` and `browser://flags`**: these are not real browser
schemes. You are most likely looking for `chrome://flags` (Chromium) or
`about:config` (Firefox).
* **`edge://flags`**: Edge is Chromium, so it works exactly like Chrome in
Extension.js.
Building an extension that needs specific flags at launch? Set them once in
`extension.config.*` and Extension.js applies them every run. See
[`browserFlags`](/docs/browsers/browser-flags) below.
## Template examples
### `new-browser-flags`
See browser flags in action with a new-tab extension that configures launch behavior.
```bash npm theme={null}
npx extension@latest create my-extension --template=newtab-browser-flags
```
```bash pnpm theme={null}
pnpx extension@latest create my-extension --template=newtab-browser-flags
```
```bash yarn theme={null}
yarn dlx extension@latest create my-extension --template=newtab-browser-flags
```
```bash bun theme={null}
bunx extension@latest create my-extension --template=newtab-browser-flags
```
```bash deno theme={null}
deno run -A npm:extension@latest create my-extension --template=newtab-browser-flags
```
Repository: [extension-js/examples/newtab-browser-flags](https://github.com/extension-js/examples/tree/main/examples/newtab-browser-flags)
## How it works
Configure flags in `extension.config.*`:
* `browser..browserFlags`
* `commands.dev|start|preview.browserFlags`
* Optional `excludeBrowserFlags` to remove defaults or user flags (behavior depends on whether you target Chromium or Firefox).
Override order: browser defaults → command defaults → CLI-selected command context.
## Flag capabilities
| Config key | What it does |
| -------------------------------------- | -------------------------------------------------------- |
| `browser..browserFlags` | Sets default launch flags for a specific browser target. |
| `commands.dev.browserFlags` | Adds or overrides flags for `dev` runs. |
| `commands.start.browserFlags` | Adds or overrides flags for `start` runs. |
| `commands.preview.browserFlags` | Adds or overrides flags for `preview` runs. |
| `browser..excludeBrowserFlags` | Removes matching default or user flags for a target. |
| `commands..excludeBrowserFlags` | Removes flags in a command-specific context. |
### Example configuration
```js theme={null}
export default {
browser: {
chrome: {
browserFlags: ["--disable-web-security", "--auto-open-devtools-for-tabs"],
excludeBrowserFlags: ["--mute-audio"],
},
firefox: {
browserFlags: ["--devtools", "--new-instance"],
excludeBrowserFlags: ["--devtools"],
},
},
};
```
## Chromium vs Firefox behavior
* **Chromium family (`chrome`, `edge`, `chromium`, `chromium-based`)**
* Starts from an internal default flag set, then appends your `browserFlags`.
* `excludeBrowserFlags` removes matching default and user flags.
* Extension.js manages `--load-extension=...` and filters it out from user-provided flags.
* **Firefox/Gecko family (`firefox`, `gecko-based` / `firefox-based`)**
* Uses user-provided `browserFlags` (no large default flag bundle like Chromium).
* `excludeBrowserFlags` removes matching user flags, with the same rule as Chromium.
Both families share one exclusion rule: a flag is removed when it equals the exclude value, or when the exclude value names a switch whose value continues with `=` or `,`. So `--disable-features` also removes `--disable-features=Translate`, while `--dev` never removes `--devtools`.
### Default Chromium flags
Extension.js applies these flags automatically when launching Chromium-family browsers. Use `excludeBrowserFlags` to remove any you do not need.
| Flag | Purpose |
| ---------------------------------------------------------- | ------------------------------------------------------------------------ |
| `--no-first-run` | Disable first run experience |
| `--disable-client-side-phishing-detection` | Disable phishing detection |
| `--disable-sync` | Disable sync to avoid account prompts |
| `--disable-component-extensions-with-background-pages` | Disable built-in extensions not affected by `--disable-extensions` |
| `--disable-default-apps` | Disable installation of default apps |
| `--disable-features=InterestFeedContentSuggestions` | Disable Discover feed on new tab page (NTP) |
| `--disable-features=Translate` | Disable Chrome translation |
| `--hide-scrollbars` | Hide scrollbars from screenshots |
| `--mute-audio` | Mute any audio |
| `--no-default-browser-check` | Disable default browser check prompt |
| `--ash-no-nudges` | Avoid user education nudges |
| `--disable-search-engine-choice-screen` | Disable search engine choice screen |
| `--disable-features=MediaRoute` | Disable Chrome Media Router background networking |
| `--use-mock-keychain` | Use mock keychain on Mac to prevent blocking dialogs |
| `--disable-background-networking` | Disable background network services |
| `--disable-breakpad` | Disable crashdump collection |
| `--disable-component-update` | Disable component updates |
| `--disable-domain-reliability` | Disable domain reliability monitoring |
| `--no-pings` | Disable hyperlink auditing pings |
| `--enable-features=SidePanelUpdates` | Ensure side panel is visible |
| `--disable-features=DisableLoadExtensionCommandLineSwitch` | Allow `--load-extension` at the command line |
| `--disable-features=ExtensionDisableUnsupportedDeveloper` | Keep unpacked extensions alive across `runtime.reload()` (Chromium 152+) |
| `--enable-unsafe-extension-debugging` | Allow Chrome DevTools Protocol (CDP) extension management (Chrome 126+) |
| `--silent-debugger-extension-api` | Suppress the "X is debugging this browser" infobar |
### Always-on session flags
Beyond the default table, every Chromium launch gets a fixed block of session-stability flags. They keep background timers, occluded windows, and renderer throttling from distorting automated and AI-driven sessions. `excludeBrowserFlags` cannot remove them:
```text theme={null}
--disable-background-timer-throttling
--disable-renderer-backgrounding
--disable-backgrounding-occluded-windows
--disable-features=TranslateUI
--disable-hang-monitor
--disable-prompt-on-repost
--memory-pressure-off
--max_old_space_size=4096
--disable-dev-shm-usage
```
In `dev` mode only, Extension.js also wires the debugging channel with three flags: `--remote-debugging-port=`, `--remote-debugging-address=127.0.0.1`, and `--remote-debugging-pipe`. Production-mode launches (`start`, `preview`) skip this trio.
### Feature-switch merging
Chromium honors only the last occurrence of a repeated switch. Extension.js therefore merges every `--enable-features` and `--disable-features` occurrence into one comma-joined switch each. Defaults, config, and environment values all merge, and duplicate feature names are removed. You can safely pass `--disable-features=Foo` in `browserFlags` without erasing the defaults.
### Environment switches
* `EXTENSION_BROWSER_FLAGS` appends whitespace-separated flags to every launch. It is applied after config flags and after `excludeBrowserFlags` filtering, so `excludeBrowserFlags` cannot remove flags that come from this variable.
* `EXTENSION_HEADLESS=1` (or `true`) forces `--headless=new`, unless a `--headless` flavor was already passed through config or environment flags. Use it as a focus-steal guard for automated sessions.
In Docker, continuous integration (CI), or containerized environments,
Extension.js also applies `--no-sandbox` and `--disable-setuid-sandbox`
automatically. On Linux it detects a container through any of these signals:
`CI=true`, a `/.dockerenv` file, a `/run/.containerenv` file,
`REMOTE_CONTAINERS=true`, `CODESPACES=true`, or a set `container` variable.
## Supported targets and references
| Browser | Usage | More information |
| -------------- | ---------------------------------------- | -------------------------------------------------------------------------------------- |
| Chrome | `extension dev --browser=chrome` | [Chrome Flags](https://peter.sh/experiments/chromium-command-line-switches/) |
| Edge | `extension dev --browser=edge` | [Edge Flags](https://docs.microsoft.com/en-us/deployedge/microsoft-edge-policies) |
| Firefox | `extension dev --browser=firefox` | [Firefox Flags](https://developer.mozilla.org/en-US/docs/Mozilla/Command_Line_Options) |
| Chromium-based | `extension dev --browser=chromium-based` | [Chromium Flags](https://peter.sh/experiments/chromium-command-line-switches/) |
| Gecko-based | `extension dev --browser=gecko-based` | Firefox-based browsers share the same flags as Firefox. |
Use binary flags when needed for engine-based targets:
* `--chromium-binary=...`
* `--gecko-binary=...`
## Best practices
* **Add only necessary flags**: Minimize long flag lists to reduce flaky or non-portable setups.
* **Prefer `excludeBrowserFlags` over replacing defaults**: Remove only what conflicts with your workflow.
* **Do not pass `--load-extension` manually**: Extension.js manages extension loading flags internally.
* **Validate per browser family**: A flag working in Chromium may be invalid or ignored in Firefox.
## Next steps
* Learn more about [Browser preferences](/docs/browsers/browser-preferences).
* Learn more about [Browser profile](/docs/browsers/browser-profile).
* Choose a target in [Browsers available](/docs/browsers/browsers-available).
* Wire flags through [`extension.config.*`](/docs/features/extension-configuration).
* New to extension tooling? Start with a [browser extension framework](/docs/compare) overview.
# Firefox and Gecko runtime preferences setup
Source: https://extension.js.org/docs/browsers/browser-preferences
Set Firefox and Gecko runtime preferences for development without editing extension source. Configure homepage, devtools, and notification defaults.
Get repeatable browser behavior during development (for example, homepage
defaults, devtools settings, or notification behavior) without editing extension
code.
Extension.js reads `preferences` from `extension.config.*` and applies them at browser launch. Firefox and Gecko targets get a `user.js` file. Chromium targets get a seeded `Default/Preferences` file.
## How it works
Configure preferences in `extension.config.js` (or `.mjs` / `.cjs`) under:
* `browser..preferences`
* `commands.dev|start|preview.preferences`
Command-level values can override browser defaults.
## Preference capabilities
| Preference key | What it does |
| ------------------------------ | ------------------------------------------------------- |
| `browser..preferences` | Sets default preferences for a specific browser target. |
| `commands.dev.preferences` | Sets or overrides preferences for `dev` runs. |
| `commands.start.preferences` | Sets or overrides preferences for `start` runs. |
| `commands.preview.preferences` | Sets or overrides preferences for `preview` runs. |
## Firefox and Gecko-based behavior
### Example configuration
```js theme={null}
export default {
browser: {
firefox: {
preferences: {
"browser.startup.homepage": "https://developer.mozilla.org",
"devtools.theme": "dark",
"dom.webnotifications.enabled": false,
},
},
},
};
```
In Firefox/Gecko flows, Extension.js writes a `user.js` file into the active profile (managed or explicit profile) and merges:
* Internal baseline preferences required for development and runtime behavior.
* Your custom `preferences` values (your values win on key conflicts).
If you enable system profile mode (`EXTENSION_USE_SYSTEM_PROFILE=true`), Extension.js does not write a managed profile file.
## Chromium-family behavior
Chromium-family launches (`chrome`, `edge`, `chromium`, `chromium-based`) seed your `preferences` too, through the profile rather than a `user.js` file.
At launch, Extension.js deep-merges your `preferences` object into the vendor's master-preferences baseline. The result is written once to `Default/Preferences` inside the active profile. The write happens only when that file does not exist yet, so a fresh profile gets seeded and an existing profile keeps its state. Chrome and Edge each have their own baseline object, chosen by the target you run.
```js theme={null}
export default {
browser: {
chrome: {
preferences: {
download: { prompt_for_download: false },
},
},
},
};
```
Your values win over the baseline on key conflicts, and nested objects merge key by key.
Because the write is once per profile, a persisted profile (`persistProfile`
or `keepProfileChanges`) does not pick up later preference edits. Delete the
profile, or run an ephemeral profile, to re-seed.
For launch behavior that Chromium controls through the command line, use flags instead:
* `browserFlags`
* `excludeBrowserFlags`
* `profile` / `persistProfile`
For CI or harnesses that must add launch flags without touching `extension.config.js`, set `EXTENSION_BROWSER_FLAGS` (whitespace-separated, for example `--headless=new`). It applies to every launched browser and is appended after config `browserFlags`, so the environment wins when a flag repeats.
## Dark mode defaults
Extension.js injects dark-mode defaults unless you already define those keys:
* Chromium family: dark-mode launch flags
* Firefox/Gecko family: dark-mode preference keys (for UI + content color scheme)
Your explicit `preferences`/flags override these defaults.
## Interface example
```js theme={null}
export default {
commands: {
dev: {
browser: "firefox",
preferences: {
"devtools.theme": "dark",
},
},
},
};
```
### Example with custom profile
```js theme={null}
export default {
browser: {
firefox: {
profile: "path/to/custom-profile",
preferences: {
"browser.startup.homepage": "https://example.com",
},
},
},
};
```
## More detailed preference references
For a comprehensive list of available Firefox preferences, explore the [Firefox source code](https://searchfox.org/mozilla-central/source/). Mozilla defines many defaults in `all.js` or `firefox.js`.
## Best practices
* **Prefer browser-scoped preferences**: Keep Firefox/Gecko preference keys under browser-targeted configuration blocks.
* **Use command overrides for temporary experiments**: Put short-lived preference tweaks in `commands.dev`.
* **Keep profiles isolated**: Use separate profiles for reproducible debugging.
* **Use flags for Chromium launch tuning**: Preferences seed the profile once, so per-run behavior changes belong in flags.
## Next steps
* Learn more about [Browser flags](/docs/browsers/browser-flags).
* Learn more about [Browser profile](/docs/browsers/browser-profile).
# Browser profile management for isolated dev runs
Source: https://extension.js.org/docs/browsers/browser-profile
Manage browser state isolation during extension development with managed, persistent, or custom profile paths for Chrome and Firefox.
Control browser state isolation during development with managed, persistent, or custom profiles.
Keep browser sessions isolated or persistent based on your workflow. Extension.js launches browsers with profile-aware defaults. Choose clean runs, reusable state, or an explicit local profile path.
## How it works
Extension.js chooses the profile mode in this order:
1. System profile mode when `profile: false` or `EXTENSION_USE_SYSTEM_PROFILE=true`
2. Explicit `profile` path (if provided)
3. Managed profile mode (default)
* temporary (ephemeral) by default
* persistent when `persistProfile: true` or `keepProfileChanges: true`
### How `--profile` values are read
The CLI delivers flag values as strings, so Extension.js normalizes them:
* `--profile=false` (or `profile: false` in config) means the browser's own default profile.
* `--profile=true` means the managed default, the same as leaving the option unset.
* Any other string is an explicit profile path.
A relative `--profile` path resolves against the project (compilation context), not your shell's working directory. This keeps sequential runs of different projects from collapsing onto one shared profile.
## Profile capabilities
| Config / option | What it does |
| ----------------------------------- | ---------------------------------------------------------------------------------------- |
| `browser..profile` | Uses an explicit profile folder for that browser target. |
| `commands.dev.profile` | Uses an explicit profile only for `dev`. |
| `commands.start.profile` | Uses an explicit profile only for `start`. |
| `commands.preview.profile` | Uses an explicit profile only for `preview`. |
| `browser..persistProfile` | Reuses managed profile state between runs for a target. |
| `commands..persistProfile` | Reuses managed profile state in a command-specific context. |
| `keepProfileChanges` | Keeps the managed profile and its changes across runs (same effect as `persistProfile`). |
| `copyFromProfile` | Seeds the managed profile as a copy of an existing profile directory. |
| `--profile=/abs/path` | CLI override for explicit profile path. |
| `--profile=false` | Launches the browser's own default profile (system mode). |
| `EXTENSION_USE_SYSTEM_PROFILE=true` | Uses the OS/browser system profile instead of managed profiles. |
| `EXTJS_USE_SYSTEM_PROFILE=true` | Alias of `EXTENSION_USE_SYSTEM_PROFILE`. |
### Seeding with `copyFromProfile`
`copyFromProfile` copies the source directory into the managed profile before launch. The copy happens only when the target is fresh, meaning it does not exist or is empty. A persisted profile therefore seeds once, and your later changes survive every run.
## Profile modes
| Mode | How to enable | Typical use |
| ----------------------- | -------------------------------------------------------- | --------------------------------------------- |
| Managed ephemeral | default | clean runs with isolated state |
| Managed persistent | `persistProfile: true` or `keepProfileChanges: true` | iterative debugging with stable browser state |
| Explicit custom profile | `profile: "/abs/path"` or `--profile=/abs/path` | reuse an existing profile |
| System profile | `--profile=false` or `EXTENSION_USE_SYSTEM_PROFILE=true` | launch with OS/browser default profile |
Extension.js creates managed profiles under:
* `dist/extension-js/profiles/-profile/<...>`
Persistent mode uses:
* `dist/extension-js/profiles/-profile/dev`
Each ephemeral run gets a generated three-word leaf name in adjective-color-animal form, for example `brave-magenta-heron`. The name is random per run, so do not hardcode it. Read the `profilePath` field in the session's `ready.json` to find the profile a run is using.
## Configure in `extension.config.*`
```js theme={null}
export default {
browser: {
chrome: {
// Use your own profile folder:
profile: "path/to/custom/profile",
},
firefox: {
// Keep a stable managed profile for repeated sessions:
persistProfile: true,
},
edge: {
// Keep changes across runs and seed once from an existing profile:
keepProfileChanges: true,
copyFromProfile: "/path/to/existing/profile",
},
},
};
```
You can also scope profile defaults by command:
```js theme={null}
export default {
commands: {
dev: {
persistProfile: true,
},
},
};
```
## CLI usage
Use an explicit profile path directly:
```bash theme={null}
extension dev --browser=chrome --profile=/path/to/custom/profile
```
Works similarly with `start` and `preview`.
## Lifecycle notes
* Extension.js creates ephemeral managed profiles for each run.
* Extension.js reuses the persistent managed profile (`dev`) across runs.
* Each ephemeral profile carries a `.extension-js-managed-profile` marker file. On browser exit, Extension.js removes only directories that carry the marker, so kept and explicit profiles are never reclaimed.
* Extension.js also sweeps stale marked profiles on the next launch. Set `EXTENSION_TMP_PROFILE_MAX_AGE_HOURS` to control the maximum age (default 12 hours).
* On every Firefox launch, Extension.js deletes the profile's `startupCache` directory. A pinned or persisted profile can otherwise serve stale extension code across a full dev restart.
## Locked Chromium profiles
If another live browser process on the same machine owns the profile,
Extension.js refuses to launch instead of corrupting it. Close that browser or
choose a different profile first.
Before a Chromium launch, Extension.js reads the profile's `SingletonLock` artifact. A lock that names a dead process or another host is stale. Extension.js removes the stale `SingletonLock`, `SingletonSocket`, and `SingletonCookie` files and launches normally. When the owning process is still alive on this host, the launch aborts instead. The session's `ready.json` is stamped with the `profile_locked` error code, so machine consumers never parse the error sentence.
## Privacy
A managed profile is a full browser profile. It holds cookies, history, and login data from anything you do in that browser session. Extension.js writes a `.gitignore` with a `*` rule into `dist/extension-js` once, so profiles and session state never reach git. Do not commit or ship this directory.
## Best practices
* **Use managed ephemeral profiles for baseline testing**: Reduces hidden state and flaky reproductions.
* **Use `persistProfile` for long-lived debug sessions**: Keep auth/session/devtools state between runs.
* **Keep custom profiles per browser family**: Avoid cross-browser contamination.
* **Use system profile mode intentionally**: Useful for reproduction, but less isolated than managed profiles.
## Next steps
* Learn more about [Browser preferences](/docs/browsers/browser-preferences).
* Learn more about [Browser flags](/docs/browsers/browser-flags).
# Supported browsers for Extension.js development
Source: https://extension.js.org/docs/browsers/browsers-available
See which browsers Extension.js supports. Run and test extensions across Chrome, Edge, Firefox, and custom binaries from a single CLI workflow.
See which browsers Extension.js supports and validate your extension across
Chrome, Edge, Firefox, and custom binaries from a single CLI workflow within the
same project.
## Choose the right target
| Target | Use when | Example |
| ---------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `chromium` | Fast default local development | `extension dev --browser=chromium` |
| `chrome` | Validating Chrome-specific behavior | `extension dev --browser=chrome` |
| `edge` | Validating Edge distribution behavior | `extension dev --browser=edge` |
| `firefox` | Validating Gecko compatibility and APIs | `extension dev --browser=firefox` |
| `chrome,firefox` | Release checks across both major engines | `extension build --browser=chrome,firefox` |
| Named forks | Running an installed fork by name (auto-located) | `extension dev --browser=brave` (also `opera`, `vivaldi`, `yandex`, `waterfox`, `librewolf`) |
| `chromium-based` | Custom Chromium binaries in `dev`; family-generic `build` artifacts | `extension dev --browser=chromium-based --chromium-binary=/path/to/browser` |
| `gecko-based` | Custom Firefox-family binaries in `dev`; family-generic `build` artifacts | `extension dev --browser=gecko-based --gecko-binary=/path/to/browser` |
| `safari` | Building a Safari app on macOS (Xcode required) | `extension build --browser=safari` |
## How it works
Use `--browser` to choose a target in `dev`, `start`, `preview`, and `build`.
If you do not specify a browser, the CLI defaults to `chromium`.
`--browser` accepts exactly these values, alone or comma-separated:
```text theme={null}
chrome | chromium | edge | firefox | brave | opera | vivaldi | yandex |
waterfox | librewolf | chromium-based | gecko-based | firefox-based |
safari | webkit-based
```
`--browser=all` is also accepted and expands to `chrome, edge, firefox`.
Prefer `chromium` (or Chrome for Testing via `npx extension install chrome`)
over branded Chrome for development. Recent branded Chrome builds (150+) drop
the `--load-extension` switch unless a policy disables that behavior. A
dropped switch looks exactly like a healthy launch. When Extension.js cannot
confirm the load, it warns and points you at `chrome://extensions`.
`safari` (and its `webkit-based` alias) is the exception: it's a **macOS-only** build target supported by `build` and `dev` only, not `preview` or `start`. See [Building Safari extensions](/docs/browsers/safari).
## Requested target vs. launch binary
The browser you request determines the artifact. The binary is just the runtime.
When you run `extension dev --browser=chromium`, Extension.js always:
* Writes output to `dist/chromium` (the folder is named after the requested target, never after the binary that launches).
* Resolves [browser-specific manifest fields](/docs/features/browser-specific-fields) for the requested target.
If the requested browser is not installed, Extension.js does not change the target. For `chrome` and `chromium` it looks for another managed Chromium-family binary (one previously downloaded by `npx extension install`) and uses it as the runtime instead:
* Requested `chromium` missing: falls back to managed Chrome, then managed Edge.
* Requested `chrome` missing: falls back to managed Chromium, then managed Edge.
When this happens, the CLI prints a warn naming the missing browser and the fix, for example `npx extension install chromium`. The warn deliberately omits the substitute binary path. The session's identity card already carries a Binary row that names the exact binary in use, so the fact is printed once. The output folder and the emitted manifest are exactly what they would be without the fallback.
### What `--browser=edge` does without a managed Edge
`edge` never swaps to another browser. Extension.js resolves the Edge binary in this order:
1. The path in the `EDGE_BINARY` environment variable, when set.
2. A managed Edge from `npx extension install edge`, or the Edge that is installed on your system.
When neither resolves, the command prints install guidance (`npx extension install edge`) and exits with code `1`. There is no silent Chromium substitute for a missing Edge.
If a Chromium window still surprises you, check the identity card. Its Binary row names the exact binary that launched, and its provenance label tells you why. A requested `chrome` or `chromium` can borrow a managed family binary (see above), but a requested `edge` cannot. The output contract holds either way: `dist/edge` exists only when `edge` was the requested target.
### Binary provenance labels
The identity card labels where the session's binary came from:
| Label | Meaning |
| ---------- | -------------------------------------------------------------------- |
| `managed` | A binary from the managed cache (`npx extension install `). |
| `pinned` | A binary you pinned with `--chromium-binary` or `--gecko-binary`. |
| `system` | A browser found on your system. |
| `snapshot` | The managed Chromium tip-of-tree snapshot. |
### Chromium snapshots vs stable
The managed `chromium` install is a tip-of-tree snapshot, not a stable release. When a stable system Chromium exists, Extension.js swaps to it automatically and prints a warn with the opt-out. Set `EXTENSION_PREFER_CHROMIUM_SNAPSHOT=true` to keep the cached snapshot instead.
Within the Chromium family this substitution is safe: `dist/chrome` and `dist/chromium` are byte-identical because manifest prefixes resolve per engine family, not per vendor. See [Browser-specific manifest fields](/docs/features/browser-specific-fields) for the prefix rules.
To pre-install managed binaries for reproducible runs, use `npx extension install ` or `npx extension install all` (which covers `chromium` too).
## Supported browsers
Named browser targets:
| Browser | Usage |
| ------------ | -------------------------------------- |
| **Chrome** | `npx extension dev --browser=chrome` |
| **Edge** | `npx extension dev --browser=edge` |
| **Firefox** | `npx extension dev --browser=firefox` |
| **Chromium** | `npx extension dev --browser=chromium` |
Named forks (auto-located from your system, no binary path required):
| Browser | Engine | Usage |
| ------------- | -------- | --------------------------------------- |
| **Brave** | Chromium | `npx extension dev --browser=brave` |
| **Opera** | Chromium | `npx extension dev --browser=opera` |
| **Vivaldi** | Chromium | `npx extension dev --browser=vivaldi` |
| **Yandex** | Chromium | `npx extension dev --browser=yandex` |
| **Waterfox** | Gecko | `npx extension dev --browser=waterfox` |
| **LibreWolf** | Gecko | `npx extension dev --browser=librewolf` |
If a named fork is not installed, Extension.js exits with install guidance. See [Running other browsers](/docs/browsers/running-other-browsers).
Engine-based targets (custom binary required):
| Engine target | Usage |
| --------------------------------- | ------------------------------------------------------------------------------- |
| **Chromium-based** | `npx extension dev --browser=chromium-based --chromium-binary=/path/to/browser` |
| **Gecko-based** (`firefox-based`) | `npx extension dev --browser=gecko-based --gecko-binary=/path/to/browser` |
Extension.js treats `firefox-based` as a Gecko engine target internally.
### What engine targets are for
`chromium-based` and `gecko-based` target an engine family instead of one vendor.
In `dev`, `start`, and `preview`, they run a binary that has no named target. Think a nightly fork, an in-house build, or a fork that the built-in locators do not know.
In `build`, they exist for distribution, and no binary is involved at all:
* `extension build --browser=chromium-based` writes a family-generic artifact to `dist/chromium-based`.
* That artifact resolves `chromium:` manifest prefixes, reads `.env.chromium-based`, and sets `EXTENSION_BROWSER=chromium-based`.
* Ship that one package to users on Chrome, Brave, Edge, or any other Chromium fork.
Choose a named target (`chrome`, `edge`) when a store build needs vendor-specific manifest fields. Choose an engine target when one artifact should serve the whole family. See [What engine targets mean for `build`](/docs/commands/build#what-engine-targets-mean-for-build).
## Safari and other WebKit targets
In addition to the Chromium family and Firefox (Gecko engine), Extension.js can build your extension into a **Safari** app on macOS.
| Target | Usage |
| ------------------------------- | -------------------------------------------- |
| **Safari** | `npx extension build --browser=safari` |
| **WebKit-based** (engine alias) | `npx extension build --browser=webkit-based` |
Safari is a **build target**: `build` and `dev` are supported, but `preview` and `start` are not (Safari extensions can't be auto-loaded into a live browser). It requires macOS with the full Xcode app. See [Building Safari extensions](/docs/browsers/safari) for the full workflow, requirements, and how to enable the extension in Safari.
## Multi-browser selection
You can run multiple named browsers in one command:
```bash theme={null}
npx extension dev --browser=chrome,firefox
```
Use comma-separated values to run multiple named targets in sequence (for example, `--browser=chrome,edge,firefox`).
## Constraints and behavior
* `chromium-based` requires `--chromium-binary` in commands that launch a browser (`dev`, `start`, `preview`); `build` needs no binary.
* `gecko-based` / `firefox-based` require `--gecko-binary` under the same conditions.
* Engine-based targets route to the same Chromium/Firefox runners with engine-aware behavior.
* As `build` targets, engine targets get their own `dist/` folder, `.env.` resolution, `EXTENSION_BROWSER` value, and manifest prefix. See [What engine targets mean for `build`](/docs/commands/build#what-engine-targets-mean-for-build).
## Best practices
* **Use named browsers for daily iteration**: `chrome`, `edge`, and `firefox` are the fastest path for regular testing.
* **Use engine-based mode intentionally**: Prefer `chromium-based` / `gecko-based` when validating custom binaries or shipping a family-generic build.
* **Keep profiles isolated per browser**: Reduce cross-browser state leakage while debugging.
* **Pair with browser-specific fields**: Use browser-prefixed manifest keys for true behavior differences.
## Next steps
* [Customize browser flags](/docs/browsers/browser-flags).
* [Customize browser preferences](/docs/browsers/browser-preferences).
* [Run other browsers from custom binaries](/docs/browsers/running-other-browsers).
* [Build Safari extensions on macOS](/docs/browsers/safari).
# Browser targeting guide for Chrome, Firefox, and Edge
Source: https://extension.js.org/docs/browsers/index
Configure browser targets for Chrome, Firefox, and Edge extension development. Covers targeting, profiles, flags, and custom binaries in Extension.js.
Run one extension codebase across Chrome, Edge, Firefox, and custom binaries
with explicit browser targeting workflows.
## Chrome extension development
For Chrome extension development, target `chrome` (or `chromium` for the default Chromium binary): `extension dev --browser=chrome`. Extension.js loads the extension into a fresh, isolated profile and applies a sane set of [Chrome launch flags](/docs/browsers/browser-flags) so reload behavior is predictable.
## Firefox extension development
For Firefox extension development, target `firefox`: `extension dev --browser=firefox`. Manifest V3 background scripts compile to a non-persistent `scripts` array (Firefox does not use `service_worker`), and Firefox preferences replace the Chromium concept of `chrome://flags`. See [Browser preferences](/docs/browsers/browser-preferences).
## Edge extension development
For Edge extension development, target `edge`: `extension dev --browser=edge`. Edge shares the Chromium engine, so most flags and APIs match Chrome, but Extension.js still emits a separate `dist/edge` artifact for distribution.
## Cross-browser extension development
For cross-browser extension development, run multiple targets in one command (`extension dev --browser=chrome,firefox`) and keep browser differences in [browser-prefixed manifest fields](/docs/features/browser-specific-fields). One project, one `manifest.json`, distinct outputs per browser. See [Cross-browser compatibility](/docs/features/cross-browser-compatibility) for the full pipeline.
You can also run two dev sessions concurrently, one terminal per browser. Session state (ready contract, control channel, logs, profile) is keyed per browser, so a Chrome session and a Firefox session never collide. See [Session artifacts](/docs/concepts/session-artifacts) for the on-disk layout.
## What to read first
| Need | Read this |
| ------------------------------ | --------------------------------------------------------------- |
| Choose browser targets quickly | [Browsers available](/docs/browsers/browsers-available) |
| Customize launch behavior | [Browser flags](/docs/browsers/browser-flags) |
| Configure Firefox preferences | [Browser preferences](/docs/browsers/browser-preferences) |
| Control profile isolation | [Browser profile](/docs/browsers/browser-profile) |
| Run Brave or custom binaries | [Running other browsers](/docs/browsers/running-other-browsers) |
| Develop under WSL | [WSL support](/docs/browsers/wsl) |
## Practical target strategy
1. Use named targets (`chrome`, `edge`, `firefox`) for daily checks.
2. Use comma-separated targets for release validation.
3. Use engine targets only when you need custom binaries.
4. Keep browser differences in browser-prefixed manifest fields.
## Next steps
* Learn manifest filtering in [Cross-browser compatibility](/docs/features/cross-browser-compatibility).
* Configure browser-specific keys in [Browser-specific manifest fields](/docs/features/browser-specific-fields).
# Running other browsers from binary path
Source: https://extension.js.org/docs/browsers/running-other-browsers
Test extensions in Brave, Vivaldi, Waterfox, or other Chromium and Gecko browsers by providing an explicit binary path to Extension.js.
Run popular Chromium and Gecko forks either by name (Extension.js locates the
installed binary for you) or by providing an explicit binary path.
Test Brave, Opera, Vivaldi, Yandex, Waterfox, and LibreWolf from the same Extension.js workflow. Name the fork directly, or point at any custom binary with binary flags and `extension.config.*` in `dev`, `start`, and `preview`.
## Run a fork by name
These forks are first-class browser targets. Pass the name to `--browser` and Extension.js finds the installed binary on your system automatically, running it through its engine family's launcher:
| Browser target | Engine family | Auto-located |
| -------------- | ------------- | ------------ |
| `brave` | Chromium | yes |
| `opera` | Chromium | yes |
| `vivaldi` | Chromium | yes |
| `yandex` | Chromium | yes |
| `waterfox` | Gecko | yes |
| `librewolf` | Gecko | yes |
```bash theme={null}
extension dev --browser=brave
```
```bash theme={null}
extension dev --browser=waterfox
```
If the browser is not installed, Extension.js exits with install guidance. A named fork inherits its family's manifest keys, so `chromium:`/`firefox:` prefixed fields resolve correctly (see [Browser-specific manifest fields](/docs/features/browser-specific-fields)).
The `dev`, `build`, `start`, and `preview` help output all list every fork
name. The `start` and `preview` lists leave out `safari` and `webkit-based`
because those two commands refuse Safari targets by design.
## Run a custom binary
To run a browser without a built-in locator, or to override the located binary, use one of these flags:
* `--chromium-binary `
* `--gecko-binary ` (alias: `--firefox-binary `)
These binary flags override which browser binary Extension.js launches, regardless of the named browser target you selected.
## Binary capabilities
| Option / key | What it does |
| --------------------------------- | ------------------------------------------------- |
| `--chromium-binary ` | Launches a custom Chromium-family browser binary. |
| `--gecko-binary ` | Launches a custom Gecko-family browser binary. |
| `--firefox-binary ` | Alias of `--gecko-binary`. |
| `browser..chromiumBinary` | Sets default custom Chromium binary in config. |
| `browser..geckoBinary` | Sets default custom Gecko binary in config. |
| `commands..chromiumBinary` | Sets command-specific custom Chromium binary. |
| `commands..geckoBinary` | Sets command-specific custom Gecko binary. |
### CLI examples
```bash theme={null}
extension dev --browser=chromium-based --chromium-binary="/path/to/brave"
```
```bash theme={null}
extension dev --browser=firefox --gecko-binary="/path/to/firefox-developer-edition"
```
You can also use them with `start` and `preview`.
## Find the binary path per OS
The binary flags expect an executable file. An invalid path fails fast with an error instead of launching.
### macOS
On macOS, an app such as `/Applications/Brave Browser.app` is a folder, not an executable. Pass the executable inside the bundle, at `Contents/MacOS`:
```bash theme={null}
extension dev --browser=chromium-based --chromium-binary="/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
```
The executable name can differ from the app name. List the folder to find it:
```bash theme={null}
ls "/Applications/Brave Browser.app/Contents/MacOS"
```
### Windows
Quote the path and use forward slashes, which every shell accepts:
```bash theme={null}
extension dev --browser=chromium-based --chromium-binary="C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe"
```
Backslashes also work, but many shells require you to double them, as in `C:\\Program Files\\...`.
### Linux
Pass the executable that your package manager installed:
```bash theme={null}
extension dev --browser=chromium-based --chromium-binary="/usr/bin/brave-browser"
```
Run `which brave-browser` to print the path when the binary is on your `PATH`.
## Configure in `extension.config.*`
```js theme={null}
export default {
browser: {
"chromium-based": {
chromiumBinary: "/path/to/custom-chromium-browser",
},
"gecko-based": {
geckoBinary: "/path/to/custom-gecko-browser",
},
},
};
```
You can also place binary paths in command blocks:
```js theme={null}
export default {
commands: {
dev: {
chromiumBinary: "/path/to/custom-chromium-browser",
},
preview: {
geckoBinary: "/path/to/custom-gecko-browser",
},
},
};
```
## Target mapping behavior
Binary hints map to engine targets:
* `chromiumBinary` → `chromium-based`
* `geckoBinary` / `firefoxBinary` → `gecko-based`
If you provide both, Extension.js applies Chromium binary resolution first.
## Available browsers
Forks with a built-in locator run by name; anything else runs with a binary flag:
| Browser name | Type | How to run | Official website |
| ----------------------------- | ---------------------- | ------------------------------------------ | --------------------------------------------------------- |
| **Brave** | Chromium-based browser | `--browser=brave` or `--chromium-binary` | [brave.com](https://brave.com) |
| **Opera** | Chromium-based browser | `--browser=opera` or `--chromium-binary` | [opera.com](https://www.opera.com) |
| **Vivaldi** | Chromium-based browser | `--browser=vivaldi` or `--chromium-binary` | [vivaldi.com](https://vivaldi.com) |
| **Yandex** | Chromium-based browser | `--browser=yandex` or `--chromium-binary` | [browser.yandex.com](https://browser.yandex.com) |
| **Waterfox** | Gecko-based browser | `--browser=waterfox` or `--gecko-binary` | [Waterfox](https://www.waterfox.net) |
| **LibreWolf** | Gecko-based browser | `--browser=librewolf` or `--gecko-binary` | [librewolf.net](https://librewolf.net) |
| **Firefox Developer Edition** | Gecko-based browser | `--gecko-binary` | [firefox.com](https://www.mozilla.org/firefox/developer/) |
## Important constraints
* `chromium-based` requires `--chromium-binary` (or `chromiumBinary` in config). Without it the launch hard-exits with an error. There is no fallback to a system browser.
* `gecko-based` / `firefox-based` require a valid `geckoBinary` path.
* Invalid paths fail fast with a clear CLI/runtime error.
* `build` does not accept binary flags. You can use binary-based launching only with `dev`, `start`, and `preview`.
## Edge binary override
Set the `EDGE_BINARY` environment variable to launch `--browser=edge` from a specific binary, without touching config:
```bash theme={null}
EDGE_BINARY="/path/to/msedge" extension dev --browser=edge
```
If the path does not exist, the launch fails instead of silently falling back.
## Run without launching a browser
Sometimes the right browser count is zero, for example in containers, over SSH, or when you drive a browser yourself.
Pass `--no-browser` to `dev`, `start`, or `preview`:
```bash theme={null}
extension dev --no-browser
```
The dev loop stays complete. The server watches your files, and every rebuild broadcasts a reload. After the first successful compile, the terminal prints a `(no-browser mode)` banner that names the output folder.
Load that `dist/` folder into a browser that you already run. In Chromium browsers, choose "Load unpacked" at `chrome://extensions` with Developer mode on. The loaded extension keeps updating on save. See [`dev`](/docs/commands/dev) for readiness synchronization with `--wait`.
To make this the default for a command, set `noBrowser` in config. The CLI flag wins over the config value:
```js theme={null}
export default {
commands: {
dev: {
noBrowser: true,
},
},
};
```
## Opt out of injected defaults
`dev`, `start`, and `preview` inject launch defaults into every session. One visible default is dark appearance. Chromium targets get the `--force-dark-mode` and `--enable-features=WebUIDarkMode` flags. Gecko targets get the matching dark preferences.
To keep your system appearance, list the flag in `excludeBrowserFlags`:
```js theme={null}
export default {
browser: {
"chromium-based": {
chromiumBinary: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
excludeBrowserFlags: ["--force-dark-mode"],
},
},
};
```
Excluding `--force-dark-mode` drops the whole appearance bundle, including the Gecko preferences. An exclude entry also matches by switch name, so `--enable-features` removes `--enable-features=WebUIDarkMode`. See [Browser flags](/docs/browsers/browser-flags) for the default flag list and the full exclusion rules.
## Best practices
* **Pair binaries with explicit browser target**: Use `--browser=chromium-based` or `--browser=gecko-based` for predictable intent.
* **Use absolute paths**: Avoid shell-dependent path resolution issues.
* **Version-pin in continuous integration (CI) runners**: Keep browser binary paths deterministic for automated checks.
* **Combine with profile/flags carefully**: Reuse the same profile and flag strategy used for named browser targets.
## Next steps
* Learn more about [Browser preferences](/docs/browsers/browser-preferences).
* Learn more about [Browser profile](/docs/browsers/browser-profile).
# Building Safari extensions with Extension.js
Source: https://extension.js.org/docs/browsers/safari
Build your web extension into a Safari app on macOS with Extension.js. Covers requirements, the dev and build workflow, and known limitations.
Package your existing web extension into a native Safari app on macOS, with no
separate Xcode project to maintain by hand.
Safari is supported on **macOS only** and requires the full Xcode app. The
build → convert → `xcodebuild` → open pipeline covers `build` and `dev`;
`preview` and `start` are not available, and there is no live reload yet.
Use `--browser=safari` to turn the same extension you ship to Chrome and Firefox into a Safari App Extension. Extension.js bundles your code, runs Apple's `safari-web-extension-converter`, compiles the generated app with `xcodebuild`, and walks you through enabling it.
## Requirements
Safari is **macOS-only** and needs the **full Xcode app**, not just the Command Line Tools. The converter (`safari-web-extension-converter`) and `xcodebuild` ship inside `Xcode.app`.
```bash theme={null}
# Install Xcode from the Mac App Store, then point the toolchain at it:
sudo xcode-select --switch /Applications/Xcode.app
xcodebuild -runFirstLaunch
```
If Xcode is missing, `extension build`/`dev --browser=safari` fail fast, before bundling, with guidance instead of a late, confusing error.
## What it produces
`extension build --browser=safari` creates, next to your project:
| Path | What it is |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `dist/safari` | The bundled web extension (manifest, scripts, assets). |
| `dist/safari-xcode` | The generated Xcode project (app + Safari extension targets). |
| `…/Release/.app` | The compiled app that hosts your extension, signed with your team id when `--development-team` is set and ad-hoc otherwise. |
The pipeline runs end to end: **bundle → convert → `xcodebuild`**, plus **open
the app → guided enable** in `dev` (or with `build --open`). A plain `build`
stops after packaging and prints the `open` command instead.
```bash theme={null}
npx extension build --browser=safari
```
The app name and bundle identifier are derived from your manifest `name` (for example, `React Sidebar Example` → bundle id `dev.extensionjs.React-Sidebar-Example`). The project targets macOS by default.
The generated `dev.extensionjs.*` bundle id is a development placeholder. If
you plan to distribute your app, set a bundle id you own **from the first
build**. Changing it later makes Safari treat the extension as a brand-new
identity (users lose their enable state and data).
## App identity and packaging options
Both `dev` and `build` accept identity overrides (Safari targets only):
| Flag | What it does |
| -------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `--bundle-id` | User-owned reverse-DNS bundle identifier (e.g. `com.example.my-extension`). |
| `--app-name` | Override the app name (defaults to the manifest `name`). |
| `--development-team` | Apple Developer team id to sign with (e.g. `A1B2C3D4E5`). Without it the build is ad-hoc signed. |
| `--macos-only` | Generate a macOS-only Xcode project (default `true`). Pass `--macos-only false` for a universal macOS + iOS project. |
| `--force-regenerate` | Regenerate the Xcode project even when it is up to date. |
| `--safari-binary` | `dev` only: the Safari binary to open after packaging. |
| `--open` | `build` only: open the built app after packaging (off by default). |
Without `--bundle-id`, Extension.js derives `dev.extensionjs.`, where `` is your sanitized app name (non-alphanumeric runs become hyphens). A user-provided bundle id must be reverse-DNS shaped: at least two dot-separated segments of letters, digits, and hyphens, each starting with a letter. Invalid values are rejected before packaging.
The same options can live in `extension.config.js` (CLI flags win):
```js theme={null}
export default {
browser: {
safari: {
appName: "My Extension",
bundleId: "com.example.my-extension",
developmentTeam: "A1B2C3D4E5",
},
},
};
```
Changing the bundle id, app name, or manifest regenerates the Xcode project on
the next run (see the regeneration warning below).
## Building the web-extension bundle on other platforms
`extension build --browser=safari` works on Linux and Windows too: it produces
the complete `dist/safari` web-extension bundle and **skips the Xcode packaging
step** with a warning. This lets CI build the payload anywhere and a Mac (or a
macOS runner) do the convert + `xcodebuild` part later. `dev --browser=safari`
still requires macOS with Xcode, because a Safari dev loop without packaging has
nothing to run.
## Enabling the extension in Safari
How you enable the extension depends on how the app was signed. Extension.js
prints the steps that match your build when the app opens (`dev`, or
`build --open`), and confirms when macOS has registered the extension.
### Signed builds (recommended)
Pass `--development-team` with your Apple Developer team id and the app is
signed with your own certificate:
```bash theme={null}
extension dev --browser safari --development-team A1B2C3D4E5
```
Safari then lists the extension like any other, and there is one step:
1. Safari ▸ Settings ▸ Extensions ▸ turn on your extension.
The toggle survives restarts, so this is a one-time step per machine.
Turning the extension **on is not the same as giving it access to pages**.
Safari asks for website access separately, and until you grant it a content
script does not run at all: the extension is listed, enabled, and does nothing.
This holds even when your manifest declares `` in both
`content_scripts.matches` and `host_permissions`, which is the part that
surprises people coming from Chrome, where installing grants it.
In the same panel, under **Permissions**, use:
* **Always Allow on Every Website…** for a development extension you are
iterating on. It persists, so you grant it once.
* **Edit Websites…** to allow only the hosts you are testing against.
If your extension loads but nothing happens on the page, this is almost always
why. Check the permission before you go looking at your code.
To find your team id, run `xcrun security find-identity -v -p codesigning`. The
ten-character code in the certificate name is your team id, and it is also on
the Membership page of your Apple Developer account.
### Ad-hoc builds (no Apple Developer account)
Without `--development-team` the build is ad-hoc signed, which Safari treats as
unsigned. It still runs, with three steps:
1. Safari ▸ Settings ▸ Advanced ▸ check **"Show features for web developers"**.
2. Safari ▸ Develop ▸ **Allow Unsigned Extensions** (this resets every time Safari restarts).
3. Safari ▸ Settings ▸ Extensions ▸ turn on your extension.
4. Grant website access, exactly as described above. Enabling alone does not
let a content script run.
"Allow Unsigned Extensions" resets each time you launch Safari, and it cannot
be scripted or saved in preferences, so every restart costs you the same three
steps. If you have an Apple Developer account, `--development-team` is worth
it for day-to-day development, not just for shipping.
## Developing with `dev`
`extension dev --browser=safari` runs a watch loop:
```bash theme={null}
npx extension dev --browser=safari
```
* **First compile**: full package: convert, build, open the app, and print the enable steps.
* **On every save**: incremental `xcodebuild` resync (typically a couple of seconds) that updates the app's resources from the freshly rebuilt `dist/safari`.
Resyncs run in the background so the bundler loop is never blocked. A burst of saves collapses to a single follow-up resync against the newest output, so five quick saves cost one rebuild, not five. If the first full package fails, the next compile retries the full flow instead of resyncing a project that was never built.
Safari has no live-reload channel like Chromium or Firefox, so after a rebuild **refresh the page (or toggle the extension)** in Safari to pick up changes.
### When the Xcode project regenerates
The Xcode project is generated once and reused for resyncs. Staleness is decided
by a fingerprint file, `dist/safari-xcode/.manifest-fingerprint`. The v2
fingerprint stores your normalized `manifest.json` content plus the identity
inputs: app name, bundle id, and the macOS-only setting. The converter runs
again when the stored fingerprint no longer matches, or when you pass
`--force-regenerate`. Cosmetic manifest edits (key order, whitespace) do not
trigger it.
Regeneration replaces the project: customizations made in
Xcode (entitlements, capabilities, added files or targets) are **discarded**.
Only these signing settings are preserved automatically: `DEVELOPMENT_TEAM`,
`CODE_SIGN_STYLE`, and `PROVISIONING_PROFILE_SPECIFIER`. Extension.js warns
before every regeneration of an existing project; if you customized the project
in Xcode, back it up first. Delete `dist/safari-xcode` for a clean slate.
### How the bundle id is enforced
Apple's converter derives the parent app's id from the app name instead of
taking `--bundle-identifier` verbatim. After every conversion, Extension.js
rewrites both `PRODUCT_BUNDLE_IDENTIFIER` entries in the generated
`project.pbxproj`: the app target gets your bundle id, and the extension target
gets `.Extension`. This keeps the identity you configured, not the
one the converter guessed.
### What `xcodebuild` runs
The compile step uses the `Release` configuration with derived data written to
`dist/safari-xcode/.derived`, a folder worth adding to `.gitignore`. Signing
settings depend on `--development-team`. With a team id the build passes
`DEVELOPMENT_TEAM=`, `CODE_SIGN_STYLE=Automatic`, and
`-allowProvisioningUpdates`, so Xcode mints the provisioning profile it needs
without being opened. Without one it passes ad-hoc settings
(`CODE_SIGN_IDENTITY=-`, `CODE_SIGNING_REQUIRED=NO`,
`CODE_SIGNING_ALLOWED=YES`) so the embedded `.appex` still validates without an
Apple Developer account. The scheme name is
your app name for a macOS-only project, and ` (macOS)` for a universal
project.
### Registration confirmation
After opening the app, Extension.js polls `pluginkit` for the extension's
registration, about 6 tries spread over 5 seconds, and prints a confirmation or
a not-yet-registered note. Under `--no-open` (and plain `build` without
`--open`) the app never launches, so registration cannot happen yet. The poll is
skipped and the CLI prints the `open` command instead.
## Debugging in Safari
Safari doesn't support the `--logs` centralized logger (there is no automation
channel), but Web Inspector covers every extension context:
* **Background/service worker**: Safari ▸ Develop ▸ Web Extension Background
Content ▸ *your extension*.
* **Popup/options/sidebar pages**: open the surface, then right-click ▸ Inspect
Element (or Develop ▸ *your Mac* ▸ the page).
* **Content scripts**: inspect the host page, where extension script contexts appear
in the Sources tab under Extension Scripts.
If a build fails, the CLI prints the tail of the failing `xcrun`/`xcodebuild`
output, bounded to the last 50 lines and 8 KB so diagnostics stay readable.
Pass `--debug` (or set `EXTENSION_DEBUG=true`) to stream the full tool output
live instead. The converter's compatibility warnings (manifest keys Safari
doesn't support) are surfaced as warnings during packaging.
## Engine target
`safari` has an engine alias, **`webkit-based`**, that parallels `chromium-based` and `gecko-based`:
```bash theme={null}
npx extension build --browser=webkit-based
```
## Command support
| Command | Safari support |
| --------- | ---------------------------------------------------------------------------- |
| `build` | ✅ Builds and packages the Safari app. |
| `dev` | ✅ Watch + incremental rebuild (refresh in Safari to apply). |
| `preview` | ❌ Not supported. Safari extensions can't be auto-loaded into a live browser. |
| `start` | ❌ Not supported. Same reason as `preview`. |
`preview` and `start` exist to launch your extension in a running browser. Safari requires the manual, security-gated enable step above, so those commands refuse Safari targets and point you to `build` instead. Under `--output json`, both fail with `E_COMMAND_UNSUPPORTED_FOR_TARGET` (Safari is a supported browser, these commands have no Safari path).
## Limitations
* **Packaging is macOS-only.** The Xcode step needs macOS with the full Xcode app. (`build` on other platforms still produces `dist/safari`; `dev` requires macOS.)
* **No live reload.** Rebuilds are fast, but you refresh in Safari to apply them.
* **Manual one-time enable.** Toggling the extension on and granting it website access are Safari security controls and cannot be automated. On ad-hoc builds, allowing unsigned extensions is a third control with the same rule.
* **Signing stops at development.** `--development-team` signs the local app with your development certificate. Distribution signing, notarization, and App Store submission are a separate step beyond this workflow (see below).
* **macOS target only.** iOS app generation is not produced by this workflow today.
## After dev: shipping to the App Store
The Safari workflow above ends with a locally signed app, ad-hoc or development. Distributing it
(to the Mac App Store, or as a notarized direct download) is a separate
pipeline that Extension.js plans to offer through the
[extension.dev](https://extension.dev?utm_source=extension-js-org\&utm_medium=sponsor\&utm_campaign=docs-seam) platform. Until that lands, follow
Apple's own guides:
* [Distributing your Safari web extension](https://developer.apple.com/documentation/safariservices/safari_web_extensions/distributing_your_safari_web_extension) (Apple Developer Program, signing, App Store Connect).
* [Notarizing macOS software](https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution) for non–App Store distribution.
Two things from the Extension.js side make that path smooth. Do them early:
1. **Set your own `--bundle-id`** (reverse-DNS, a domain you own) from the first
build. Bundle id is the extension's identity on Apple platforms.
2. **Set your `DEVELOPMENT_TEAM` in Xcode once**: it survives project
regeneration automatically, along with `CODE_SIGN_STYLE` and
`PROVISIONING_PROFILE_SPECIFIER`.
## Best practices
* **Build other targets normally**: Safari is additive, so keep iterating in `chromium`/`firefox` and run `--browser=safari` when you want to validate Safari.
* **Use browser-specific fields** for true behavioral differences. Safari resolves the chromium-family prefixes (`chromium:`, `chrome:`, `edge:`), and `safari:`/`webkit:` prefixed keys win over them on Safari targets, for both `--browser=safari` and `--browser=webkit-based`.
* **Keep the generated project** unless you need a clean slate, because regenerating discards Xcode-side customizations beyond the preserved signing settings.
## Next steps
* See all [supported browsers](/docs/browsers/browsers-available).
* Use [browser-specific manifest fields](/docs/features/browser-specific-fields).
* Review [multi-platform builds](/docs/features/multi-platform-builds).
# Developing extensions under WSL
Source: https://extension.js.org/docs/browsers/wsl
Run Extension.js dev sessions inside Windows Subsystem for Linux. Covers the Linux-first browser ladder, the Windows .exe fallback, and the Chrome wrapper-script swap.
Run the full Extension.js dev loop inside Windows Subsystem for Linux (WSL), with the browser resolved automatically from either side of the boundary.
Extension.js detects WSL through the `WSL_DISTRO_NAME`, `WSL_INTEROP`, or `WSLENV` variables, or a Microsoft kernel signature. It then resolves a browser binary with a fixed ladder.
## The resolution ladder
1. **A Linux-native browser first.** When a GUI display is available (`DISPLAY` or `WAYLAND_DISPLAY` is set), Extension.js checks known Linux install locations. Examples are `/opt/google/chrome/chrome`, `/usr/bin/chromium`, `/snap/bin/chromium`, and `/usr/bin/firefox`. A Linux browser keeps the whole dev loop on the Linux side.
2. **A Windows `.exe` under `/mnt/c` as fallback.** When no Linux browser exists, Extension.js looks for the Windows install. It checks `/mnt/c/Program Files/Google/Chrome/Application/chrome.exe`, the matching Chromium, Edge, and Firefox paths, and their `Program Files (x86)` variants.
3. **A spawn-time retry.** If the chosen binary fails to spawn, Extension.js retries once with the Windows binary before giving up.
You can skip the ladder entirely by passing an explicit path:
```bash theme={null}
extension dev --browser=chromium-based --chromium-binary="/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
```
## The Chrome wrapper-script swap
On Linux, `google-chrome` and its channel variants are shell wrapper scripts, not the real binary. The wrapper closes extra file descriptors when it execs Chrome, which breaks the `--remote-debugging-pipe` channel the dev session depends on.
Under WSL with a GUI, Extension.js detects a wrapper by name (`google-chrome`, `google-chrome-stable`, `google-chrome-beta`, `google-chrome-dev`, `google-chrome-unstable`) and swaps it for the real binary at `/opt/google/chrome/chrome` when that file exists. Known install locations already list real binaries before wrappers for the same reason.
## Shell aliases are not paths
Extension.js launches browsers with `child_process.spawn`, which never reads
your shell configuration. A shell alias like `google-chrome=...` does not
exist for the launcher. Pass a real file path, or a wrapper script that is an
actual executable on disk.
## When nothing is found
If neither side has a browser, the CLI exits with WSL-specific guidance: install a Linux browser inside WSL, or point `--chromium-binary` at the Windows `.exe`. Firefox resolves the same way through its own ladder (`/usr/bin/firefox`, `/snap/bin/firefox`, `/opt/firefox/firefox`, then `/mnt/c/Program Files/Mozilla Firefox/firefox.exe`).
## Next steps
* Choose a target in [Browsers available](/docs/browsers/browsers-available).
* Point at custom binaries in [Running other browsers](/docs/browsers/running-other-browsers).
# Build command for production extension artifacts
Source: https://extension.js.org/docs/commands/build
Create production-ready extension artifacts for Chrome, Edge, or Firefox with the Extension.js build command. Supports multi-browser and zip output.
Create production extension artifacts for one or more browser targets.
`build` compiles your extension in production mode and writes output to `dist/`.
For monorepo/submodule projects, see [Environment variables](/docs/features/environment-variables#how-it-works) for configuration-time env resolution (project root first, then workspace-root fallback).
## When to use `build`
* Preparing extension packages for Chrome Web Store, Edge Add-ons, or Firefox Add-ons.
* Running continuous integration (CI) jobs that produce repeatable production artifacts.
* Validating production bundle output and browser-target differences before submission.
## Build command capabilities
| Capability | What it gives you |
| ---------------------- | ------------------------------------------------------------- |
| Production compilation | Generate optimized extension artifacts per target |
| Multi-target output | Build multiple browser targets in one command |
| Packaging support | Create distribution zip artifacts with optional source bundle |
| CI-friendly behavior | Keep build outputs and naming predictable in automation |
## Usage
```bash npm theme={null}
extension build [project-path] [options]
```
```bash pnpm theme={null}
extension build [project-path] [options]
```
```bash yarn theme={null}
extension build [project-path] [options]
```
```bash bun theme={null}
extension build [project-path] [options]
```
```bash deno theme={null}
extension build [project-path] [options]
```
## Build output
After running `build`, Extension.js generates optimized files for the selected browser targets. Output goes to `dist/` with one subfolder per target. Each folder contains bundled JavaScript, CSS, HTML, and required runtime assets.
For TypeScript projects, `build` also regenerates the `extension-env.d.ts`
ambient type declarations (the same file [`dev`](/docs/commands/dev) writes),
so a CI `tsc --noEmit` stays clean whether or not you ran `dev` first.
JavaScript-only projects skip this step.
**Example output structure:**
```plaintext theme={null}
dist/
├── chrome/
│ ├── manifest.json
│ ├── background/service_worker.js
│ ├── content_scripts/content-0.js
├── edge/
│ ├── manifest.json
│ ├── background/service_worker.js
│ ├── content_scripts/content-0.js
```
## Browser target matrix
| Target style | Examples | Notes |
| -------------- | ------------------------------------------------ | -------------------------------------------------------------------------- |
| Named targets | `chromium`, `chrome`, `edge`, `firefox` | Build one specific browser target |
| Engine targets | `chromium-based`, `gecko-based`, `firefox-based` | Family-generic artifacts. See [below](#what-engine-targets-mean-for-build) |
| Multi-target | `chrome,firefox` | Comma-separated targets |
### What engine targets mean for `build`
`build` never launches a browser, so engine targets don't point at a binary here, but they still produce a distinct artifact, not a renamed copy of a named-target build:
* **Own output folder.** `--browser=chromium-based` writes to `dist/chromium-based`, the same folder `dev`, `preview`, and `start` use for that target, so a project developed against a custom Chromium binary builds to matching paths.
* **Own env resolution.** `.env.chromium-based` and `.env.chromium-based.production` win over the family's `.env.chromium`/`.env.chrome`/`.env.edge`, and bundled code sees `EXTENSION_BROWSER === "chromium-based"`, so code and config can branch on "generic Chromium" vs a specific store build.
* **Own manifest prefix.** `chromium-based:` keys in `manifest.json` resolve as the most-specific match for this target, on top of the family-wide `chrome:`/`chromium:`/`edge:` keys.
`gecko-based` works the same way relative to `firefox`. No browser binary is required, and `--chromium-binary`/`--gecko-binary` only matter for commands that launch a browser.
## Arguments and flags
| Flag | Alias | What it does | Default |
| ------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `[path]` | - | Builds a local extension project. | `process.cwd()` |
| `--browser ` | - | Browser/engine target (`chromium`, `chrome`, `edge`, `firefox`, `safari`, engine aliases, or comma-separated values). | `commands.build.browser`, else `chromium` |
| `--polyfill [boolean]` | - | Enables `browser.*` API compatibility polyfill for Chromium targets. | `false` |
| `--no-polyfill` | - | Disables the cross-browser polyfill. | polyfill disabled |
| `--zip [boolean]` | - | Creates a packaged zip artifact in any mode. | `false` |
| `--zip-source [boolean]` | - | Includes source files in zip output. | `false` |
| `--zip-filename ` | - | Sets custom zip filename. | sanitized extension name + version |
| `--silent [boolean]` | - | Suppresses build logs. | `false` |
| `--mode ` | - | Bundler mode override (`development`, `production`, or `none`). Also sets `NODE_ENV`. | `production` |
| `--extensions ` | - | Comma-separated companion extensions or store URLs. | unset |
| `--install [boolean]` | - | Internal flag. Install project dependencies when missing. Lifecycle scripts stay disabled unless `EXTENSION_ALLOW_INSTALL_SCRIPTS=true`. | installs when `node_modules` is missing |
| `--output ` | - | Result format. `json` prints a schema-1 envelope on stdout. | `pretty` |
| `--debug` | - | Enable maintainer diagnostics. | disabled |
`--author` and `--author-mode` are hidden, deprecated aliases for `--debug`.
### Safari flags
These flags apply to `safari` and `webkit-based` targets only. Passing any of them with another target exits with an error, so a typo never no-ops silently.
| Flag | What it does | Default |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- |
| `--open [boolean]` | Open the built Safari app after packaging. `build` never opens it unless you ask. | `false` |
| `--app-name ` | Override the Safari app name. | the manifest `name` |
| `--bundle-id ` | User-owned bundle identifier in reverse-DNS form. A malformed value fails before any build. | a generated `dev.extensionjs.*` id |
| `--development-team ` | Apple Developer team id to sign the Safari app with. Without it the build is ad-hoc signed, which Safari treats as unsigned, so the extension needs Develop ▸ Allow Unsigned Extensions re-ticked on every launch. | unset (ad-hoc signed) |
| `--macos-only [boolean]` | Generate a macOS-only Xcode project. Pass `false` for a universal macOS + iOS project. | `true` |
| `--force-regenerate` | Regenerate the Safari Xcode project even when up to date. | disabled |
Safari packaging runs a preflight before the build. On a non-macOS host, `build` warns and skips the Safari packaging step but still compiles the bundle. On macOS with a broken or missing Xcode, the failure is fatal.
## Shared global options
Also supports [Global flags](/docs/workflows/global-flags).
## Mode override
`--mode` overrides the bundler mode and `NODE_ENV` for the build. Accepts `development`, `production`, or `none`. Use it to mirror Vite/webpack workflows where you need a non-production bundle for staging or debugging.
```bash npm theme={null}
extension build ./my-extension --mode development
```
```bash pnpm theme={null}
extension build ./my-extension --mode development
```
```bash yarn theme={null}
extension build ./my-extension --mode development
```
```bash bun theme={null}
extension build ./my-extension --mode development
```
```bash deno theme={null}
extension build ./my-extension --mode development
```
Invalid values exit with an error. The default remains `production`.
A development-mode build is shippable. It keeps the CSP and permissions you wrote in the manifest, injects no reload client, and its zip carries no source maps (the `.map` files stay in `dist/` for you). Only `extension dev` turns the dev instrumentation on. `--zip` packages the output in any mode, not only `production`.
## Zip behavior
| Option | Effect | Typical use |
| ---------------- | --------------------------------------- | ------------------------------------ |
| `--zip` | Creates a packaged artifact zip | Store submission/manual distribution |
| `--zip-filename` | Sets custom zip name | CI naming conventions |
| `--zip-source` | Adds source archive alongside artifacts | Compliance/review pipelines |
Each zip lands inside its own `dist/` folder, next to the unpacked output. Without `--zip-filename`, the name is the manifest `name` lowercased with every character outside `a-z0-9` and spaces removed, remaining spaces turned into dashes, then the manifest `version`. A manifest named `My Extension+` at version `1.0.0` packages as `dist/chrome/my-extension-1.0.0.zip`. Because the name is rewritten, read the emitted path from the build output instead of composing it from the manifest name.
## Examples
### Building with zip output and custom filename
```bash npm theme={null}
extension build ./my-extension --browser=edge,chrome --zip --zip-filename=my-extension.zip
```
```bash pnpm theme={null}
extension build ./my-extension --browser=edge,chrome --zip --zip-filename=my-extension.zip
```
```bash yarn theme={null}
extension build ./my-extension --browser=edge,chrome --zip --zip-filename=my-extension.zip
```
```bash bun theme={null}
extension build ./my-extension --browser=edge,chrome --zip --zip-filename=my-extension.zip
```
```bash deno theme={null}
extension build ./my-extension --browser=edge,chrome --zip --zip-filename=my-extension.zip
```
In this example, the build targets Edge and Chrome, zips the output, and saves it as `my-extension.zip`.
### Building with polyfill support
```bash npm theme={null}
extension build ./my-extension --browser=chrome,firefox --polyfill
```
```bash pnpm theme={null}
extension build ./my-extension --browser=chrome,firefox --polyfill
```
```bash yarn theme={null}
extension build ./my-extension --browser=chrome,firefox --polyfill
```
```bash bun theme={null}
extension build ./my-extension --browser=chrome,firefox --polyfill
```
```bash deno theme={null}
extension build ./my-extension --browser=chrome,firefox --polyfill
```
In this example, the build targets Chrome and Firefox and includes polyfill support where relevant.
### Building source and artifact zip
```bash npm theme={null}
extension build ./my-extension --zip --zip-source
```
```bash pnpm theme={null}
extension build ./my-extension --zip --zip-source
```
```bash yarn theme={null}
extension build ./my-extension --zip --zip-source
```
```bash bun theme={null}
extension build ./my-extension --zip --zip-source
```
```bash deno theme={null}
extension build ./my-extension --zip --zip-source
```
## What a successful build prints
After the asset summary, a successful build prints the output directory and its
size, then a link you can use to share the build for review:
```plaintext theme={null}
⏵⏵⏵ Extension built for production in dist/chrome (35.5 KB).
⏵⏵⏵ Send this build to someone for review: https://docs.extension.dev/share/unpublished-build-for-review
```
Each target prints its own pair of lines, so a multi-browser build repeats them
once per browser.
Builds that succeed with warnings print the warning details above those lines,
and the compile line reads `compiled with warnings` instead of `compiled in`.
Do not gate CI on this prose. It is written for people and it changes between
releases. Use the exit code, or `--output json` below, which is the supported
machine contract.
## Machine output with `--output json`
`--output json` prints one schema-1 envelope on stdout and routes the human build lines to stderr. Stdout stays parseable as a single JSON document.
* A successful run prints a `status: "built"` frame. Its `value` carries the built browsers, the resolved mode, and one summary per browser. Each summary records the output path, asset totals, warning text, and the Safari app identity when relevant.
* A failed build prints one `ok: false` frame with `status: "build-failed"` and `error.code: "E_COMPILE"` before the process exits `1`.
Every build also writes `dist/extension-js//build-summary.json`. Scripts that shell out to `extension build` can read structured warnings there. Guard against stale files by checking the file's modification time.
## Best practices
* **Check build logs:** Review logs for warnings and missing assets after each build.
* **Optimize your manifest:** Keep `manifest.json` compatible with every target browser.
* **Name artifacts intentionally:** Use `--zip-filename` for stable CI artifact naming.
* **Validate target output:** Check each `dist/` folder before publishing. A later `dev` session overwrites that same folder with a dev-instrumented build that adds permissions such as `scripting`, `tabs`, `management`, and `storage`, plus `host_permissions` for `` and broad web-accessible resources. Run `build` again before you package or publish.
## Next steps
* Send the build to a reviewer behind a link by following [Share an unpublished build for review](https://docs.extension.dev/share/unpublished-build-for-review?utm_source=extension-js-org\&utm_medium=sponsor\&utm_campaign=docs-seam).
* Submit the artifacts to the browser stores by following [the extension.dev publish docs](https://docs.extension.dev/publish/overview?utm_source=extension-js-org\&utm_medium=sponsor\&utm_campaign=docs-seam).
* Get a shareable URL for a project on extension.dev with [`publish`](/docs/commands/publish).
* Run existing build output with [`preview`](/docs/commands/preview).
* Build and launch in one command with [`start`](/docs/commands/start).
* Configure shared defaults in [`extension.config.js`](/docs/features/extension-configuration).
* Review configuration env loading behavior in [Environment variables](/docs/features/environment-variables#how-it-works).
* Review supported targets in [Browsers available](/docs/browsers/browsers-available).
# Capabilities command for engine handshakes
Source: https://extension.js.org/docs/commands/capabilities
Print the Extension.js engine version, contract schema versions, and json-capable commands in one machine-readable handshake for scripts and agents.
Print the engine version, contract versions, and json-capable commands.
`capabilities` is the handshake an agent or script runs before anything else. One call tells the caller which CLI it is talking to, which contract schemas that CLI writes, and which commands accept `--output json`. It needs no project, no session, and no browser.
## When to use `capabilities`
* An agent starts a session and must know which schema versions to parse.
* A script wants to feature-detect `--output json` support instead of guessing by version.
* You want to confirm which CLI build a `npx` invocation actually resolved.
## Usage
```bash npm theme={null}
extension capabilities [options]
```
```bash pnpm theme={null}
extension capabilities [options]
```
```bash yarn theme={null}
extension capabilities [options]
```
```bash bun theme={null}
extension capabilities [options]
```
```bash deno theme={null}
extension capabilities [options]
```
## Arguments and flags
| Flag | What it does | Default |
| ------------------------- | -------------- | ------- |
| `--output ` | Result format. | `json` |
This is the only command that defaults to `json`. A handshake exists for machines, so the machine format is the default and `--output pretty` is the opt-in.
## What it returns
The JSON output is a schema-1 envelope (see [Result envelope](/docs/contracts/result-envelope)) whose `value` carries five fields:
```json theme={null}
{
"schema": 1,
"ok": true,
"command": "capabilities",
"status": "ok",
"value": {
"name": "extension",
"version": "4.0.22",
"envelopeSchema": 1,
"readySchemaVersion": 2,
"outputJsonCommands": ["build", "capabilities", "dev", "doctor", "eval", "..."]
},
"error": null,
"warnings": []
}
```
| Field | What it tells you |
| -------------------- | ------------------------------------------------------------------- |
| `name` | The CLI package name. |
| `version` | The CLI version that answered. |
| `envelopeSchema` | The result envelope schema this CLI writes. |
| `readySchemaVersion` | The `ready.json` contract version this CLI's bundled engine writes. |
| `outputJsonCommands` | Every command that accepts `--output json`, sorted. |
`outputJsonCommands` is read off the live command registrations, never a hand-kept list, so it cannot drift from what the CLI actually accepts. Treat the example above as illustrative and parse the real answer.
Pretty output prints the same facts as four labeled lines. Both formats exit with code `0`.
## Typical agent flow
1. Run `extension capabilities` and check `envelopeSchema` and `readySchemaVersion` are versions that you support.
2. Start the session: `extension dev --allow-control --output json`.
3. Drive it with the act verbs: [`inspect`](/docs/commands/inspect), [`eval`](/docs/commands/eval), [`storage`](/docs/commands/storage), [`reload`](/docs/commands/reload).
## Next steps
* Read the envelope that every json-capable command emits in [Result envelope](/docs/contracts/result-envelope).
* Start the session that the handshake prepares for with [`dev`](/docs/commands/dev).
* Read the wider debugging workflow in [Debugging](/docs/debugging).
# Create command to scaffold extension projects
Source: https://extension.js.org/docs/commands/create
Scaffold a new browser extension project from an official template with one CLI command. Choose React, Vue, Svelte, TypeScript, or vanilla JS.
`create` scaffolds files, configuration, and starter scripts for the selected template and optionally installs dependencies.
For a file-by-file tour of the generated tree, see [What create generates](/docs/getting-started/create-your-first-extension#what-create-generates).
## When to use `create`
* Start a new extension from scratch.
* Spin up multiple proof-of-concept ideas quickly.
* Standardize onboarding for your teammates with consistent template defaults.
## Create command capabilities
| Capability | What it gives you |
| -------------------- | ----------------------------------------------------------- |
| Template scaffolding | Start with official templates and a ready project structure |
| Dependency install | Optionally install required packages after scaffold |
| Path flexibility | Create by project name or explicit folder path |
| Fast onboarding | Move from empty folder to runnable extension quickly |
## Usage
**Using Yarn?** The `yarn dlx` command requires Yarn 2 or later. Yarn 1 does
not include `dlx` and fails with a "Command not found" error. On Yarn 1, use
the `npm` tab (`npx`) instead.
```bash npm theme={null}
npx extension@latest create [options]
```
```bash pnpm theme={null}
pnpx extension@latest create [options]
```
```bash yarn theme={null}
yarn dlx extension@latest create [options]
```
```bash bun theme={null}
bunx extension@latest create [options]
```
```bash deno theme={null}
deno run -A npm:extension@latest create [options]
```
## Arguments and flags
| Flag | Alias | What it does | Default |
| ------------------------- | ----- | -------------------------------------------------------------------------------------------------------- | ------------ |
| `[path or name]` | - | Project folder/name to create. | required |
| `--template ` | `-t` | Catalog name, GitHub URL, or ZIP URL to scaffold from. | `typescript` |
| `--install [boolean]` | - | Installs dependencies after scaffolding. | `false` |
| `--source ` | - | Attribution tag for where this create started (for example `cli`). Recorded in anonymous telemetry only. | unset |
| `--output ` | - | Result format. `json` prints a schema-1 envelope on stdout. | `pretty` |
When you want the default TypeScript starter, omit `--template` entirely. Add `--template=` only for another stack from the [official examples](https://github.com/extension-js/examples/tree/main/examples). `--template` also accepts a GitHub URL or a ZIP URL, so you can scaffold from any repository.
The catalog holds 53 templates in 6 groups: starters, sidebar, content scripts, new tab, toolbar action, and special folders. Run `extension create --help` for the full list. The default `typescript` template downloads the catalog archive like every other name. Only the `javascript` template ships inside the CLI. When you omit `--template` and the download fails, `create` falls back to that bundled `javascript` template and says so, naming the network error, so an offline machine still gets a project. An explicit `--template` that fails to download fails loudly instead.
A scaffold has one package manager. A starter's `packageManager` pin (or a `pnpm-workspace.yaml` it ships) decides it, otherwise the manager that invoked `create` does. The `packageManager` field written to `package.json`, the `--install` run, and the printed next steps all name that same manager.
## Template corpus pinning
Catalog downloads are pinned to one immutable commit of the examples repository. Two scaffolds of the same version therefore produce the same bytes. Two environment variables override the pin:
* `EXTENSION_CREATE_TEMPLATE_REF` points at another ref. Set it to `main` to restore floating behavior.
* `EXTENSION_CREATE_TEMPLATE_URL` points at another archive URL entirely.
Each scaffold writes a `.extension-create.json` provenance file into the project. It records the create version, the template, and the source, plus the resolved ref when the template came from the catalog archive, so template drift stays auditable. The bundled `javascript` starter records `"source": "bundled"` and no ref.
## Machine output with `--output json`
`--output json` prints one schema-1 envelope on stdout and routes scaffold progress lines to stderr:
* A successful run prints a `status: "created"` frame. Its `value` carries `projectPath`, `projectName`, `template`, and `depsInstalled`.
* Failures print `ok: false` with an `error.code`: `E_TEMPLATE_NOT_FOUND` for an unknown catalog name, `E_NETWORK` for a failed download, `E_DESTINATION_NOT_EMPTY` or `E_DESTINATION_NOT_WRITABLE` for destination problems.
## Shared global options
Also supports [Global flags](/docs/workflows/global-flags).
## Example commands
```bash npm theme={null}
npx extension@latest create my-extension --template=newtab-react
```
```bash pnpm theme={null}
pnpx extension@latest create my-extension --template=newtab-react
```
```bash yarn theme={null}
yarn dlx extension@latest create my-extension --template=newtab-react
```
```bash bun theme={null}
bunx extension@latest create my-extension --template=newtab-react
```
```bash deno theme={null}
deno run -A npm:extension@latest create my-extension --template=newtab-react
```
```bash npm theme={null}
npx extension@latest create my-extension --install
```
```bash pnpm theme={null}
pnpx extension@latest create my-extension --install
```
```bash yarn theme={null}
yarn dlx extension@latest create my-extension --install
```
```bash bun theme={null}
bunx extension@latest create my-extension --install
```
```bash deno theme={null}
deno run -A npm:extension@latest create my-extension --install
```
## Available templates
For the full, continuously updated list of templates, browse the [examples
repository](https://github.com/extension-js/examples/tree/main/examples).
Minimal bundled starter. Use when you want a clean baseline or are offline.
Typed sidebar starter with `tsconfig.json` preconfigured.
React UI wired for content scripts and popup views.
Vue UI with single-file component (SFC) support baked in.
## Best practices
* Start from a template that matches your UI/runtime needs to reduce setup drift.
* Keep the first run small, then add extra tooling after verifying baseline command flow.
# Dev command for watch mode and live reload
Source: https://extension.js.org/docs/commands/dev
Develop browser extensions with watch mode, hot module replacement, automatic browser launch, and context-aware reload via the Extension.js dev command.
Use `dev` for day-to-day browser extension development with watch mode, browser launch, and context-aware update behavior.
`dev` runs the development pipeline and watches your project files. It applies update strategies based on what changed: hot module replacement (HMR), hard reload, or a full restart when the change requires it.
## When to use `dev`
* Building features and validating changes in real time.
* Debugging extension behavior in one or more browser targets.
Use `build` for production artifacts, `start` for production build + launch, and `preview` to run existing build output only.
If your extension lives inside a monorepo/submodule, review how `extension.config.*` loads env files (including workspace-root fallback): [Environment variables](/docs/features/environment-variables#how-it-works).
## Dev command capabilities
| Capability | What it gives you |
| ---------------------- | ----------------------------------------------------- |
| Watch mode iteration | Tight edit → rebuild → validate loop while coding |
| Browser-target control | Explicit cross-browser validation per command |
| Profile-aware runs | Reliable fresh or persisted profiles by workflow need |
## Usage
```bash npm theme={null}
extension dev [path-or-url] [options]
```
```bash pnpm theme={null}
extension dev [path-or-url] [options]
```
```bash yarn theme={null}
extension dev [path-or-url] [options]
```
```bash bun theme={null}
extension dev [path-or-url] [options]
```
```bash deno theme={null}
extension dev [path-or-url] [options]
```
If you omit the path, Extension.js uses the current working folder. You can also pass a **GitHub tree URL** (for example, `https://github.com/user/repo/tree/main/path`). Extension.js downloads the repository and runs development mode on the local copy.
## Most-used flags
These cover the 80% case. Skip to the [full reference](#arguments-and-flags) for the rest.
| Flag | What it does | Default |
| ---------------------- | ------------------------------------------------------------------------ | ---------- |
| `--browser ` | Target Chrome, Edge, Firefox, or comma-separated list. | `chromium` |
| `--polyfill` | Bridge `browser.*` API to Chromium targets for Firefox-flavored sources. | `true` |
| `--port ` | Dev server port. Use `0` for OS-assigned. | `8080` |
| `--starting-url ` | Open this URL when the browser launches. | unset |
| `--no-reload` | Skip auto-reload. Use when you want a clean dev bundle. | reload on |
## Arguments and flags
| Flag | Alias | What it does | Default |
| --------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `[path or url]` | - | Extension path or remote URL. | `process.cwd()` |
| `--browser ` | `-b` | Browser/engine target (`chromium`, `chrome`, `edge`, `firefox`, named forks like `brave`/`waterfox`, engine aliases, or comma-separated values). | `chromium` |
| `--profile ` | - | Browser profile path or boolean profile mode. | fresh profile |
| `--chromium-binary ` | - | [Custom Chromium-family binary path](/docs/browsers/running-other-browsers). | system default |
| `--gecko-binary ` | `--firefox-binary` | [Custom Gecko-family binary path](/docs/browsers/running-other-browsers). | system default |
| `--polyfill [boolean]` | - | Enable `browser.*` API compatibility polyfill for Chromium targets. | `true` |
| `--no-polyfill` | - | Disable the cross-browser polyfill. | polyfill enabled |
| `--starting-url ` | - | Starting URL in launched browser. | unset |
| `--port ` | - | Requested dev server port. Use `0` for an OS-assigned port. See [how the port resolves](#how-the-port-resolves). | `8080` |
| `--host ` | - | Host to bind the dev server to. Use `0.0.0.0` for Docker/dev containers. | `127.0.0.1` |
| `--public-host ` | - | Connectable host the browser dials for HMR and the reload bridge, when it differs from the bind `--host` (remote/dev container). | bind host (`127.0.0.1` when bound to `0.0.0.0`) |
| `--no-open` | - | Launch the browser, but do not open a tab for your extension. The browser still starts and loads it. See [stop the browser from launching](#stop-the-browser-from-launching). | a tab opens on launch |
| `--no-browser` | - | Stop the browser from launching. The dev server still starts and keeps rebuilding. See [stop the browser from launching](#stop-the-browser-from-launching). | browser launch enabled |
| `--no-reload` | - | Skip the content-script reload runtime and on-rebuild reload dispatch. Reload tabs manually to see changes. | reload runtime enabled |
| `--wait [boolean]` | - | Wait for `dist/extension-js//ready.json` and exit. Requires a local project path. | disabled |
| `--wait-timeout ` | - | Timeout for `--wait` mode. | `60000` |
| `--output ` | - | Result format. `json` prints a schema-1 envelope on stdout. | `pretty` |
| `--extensions ` | - | Comma-separated companion extensions or store URLs. | unset |
| `--install [boolean]` | - | Internal flag. Install project dependencies when missing. Lifecycle scripts stay disabled unless `EXTENSION_ALLOW_INSTALL_SCRIPTS=true`. | command behavior default |
| `--allow-control` | - | Enable the agent-bridge control channel for the bounded act verbs (`reload`, `storage`, `open`). | disabled |
| `--allow-eval` | - | Also enable `extension eval`. Implies `--allow-control` and writes a `0600` session token. | disabled |
| `--parent-pid ` | - | Exit the dev server as soon as the given process ID dies. See [parent watchdog](#parent-watchdog). | unset |
| `--debug` | - | Enable maintainer diagnostics. | disabled |
Two deprecated aliases are hidden from `--help` but still work:
* `--wait-format ` maps onto `--output` and warns once on stderr. Migrate scripts to `--output`.
* `--author` and `--author-mode` map onto `--debug`.
### Safari flags
These flags apply to `safari` and `webkit-based` targets only. Passing any of them with another target exits with `E_INVALID_OPTION`, so a typo never no-ops silently.
| Flag | What it does | Default |
| ------------------------ | ------------------------------------------------------------------------------------------- | ---------------------------------- |
| `--safari-binary ` | Safari binary to open after packaging. | system Safari |
| `--app-name ` | Override the Safari app name. | the manifest `name` |
| `--bundle-id ` | User-owned bundle identifier in reverse-DNS form. A malformed value fails before any build. | a generated `dev.extensionjs.*` id |
| `--macos-only [boolean]` | Generate a macOS-only Xcode project. Pass `false` for a universal macOS + iOS project. | `true` |
| `--force-regenerate` | Regenerate the Safari Xcode project even when up to date. | disabled |
For Safari targets, `dev` also runs a toolchain preflight before the first bundle. A missing Xcode fails fast with `E_SAFARI_TOOLCHAIN`.
### Parent watchdog
`--parent-pid` is for harnesses and agents that spawn `dev`, so a crashed owner cannot leak a server. The value must be a positive integer, anything else exits with `E_INVALID_OPTION`. The watchdog polls the parent process every 2 seconds. When the parent is gone, the dev server shuts down via `SIGTERM`, with a 5 second hard-exit backstop if cleanup wedges.
### How the port resolves
`--port` is a request, not a guarantee. When the requested port is busy, the dev server walks upward to the nearest free port. `--port 0` asks the OS for any free port. Read the bound port from `ready.json`, not from the flag you passed.
## Automation metadata (recommended for scripts/agents)
When `dev` runs, Extension.js emits machine-readable metadata under:
* `dist/extension-js//ready.json`
* `dist/extension-js//events.ndjson` (newline-delimited JSON)
For automation (Playwright, continuous integration (CI), AI agents), prefer these files over terminal log parsing.
Treat `ready.json` as the readiness contract:
* `status: "starting"` while booting
* `status: "ready"` when the build compiled
* `status: "error"` for startup/compile failures
* `status: "stopped"` after the session shut down, so a dead session never advertises `ready`
* `runtime: "attached"` (with `executorAttachedAt`) once the service worker connected. Act verbs should wait for this, not for `ready`
* `runId` uniquely identifies a runtime session
* `startedAt` marks the runtime session start timestamp
* `command` names the producing command (`dev`, `start`, `preview`, or `build`)
* `toolchainVersion`, `extensionName`, and `extensionVersion` record which Extension.js version produced the tree, for which extension. The file doubles as a build receipt after the terminal scrollback is gone
* `port` is the bound dev server port, and `host` is the connectable host clients dial (see [bind host vs. connectable host](#bind-host-vs-connectable-host))
* `controlPort` / `instanceId` locate the control bridge used by `extension logs` and the act verbs
* `cdpPort` (Chromium) and `rdpPort` (Gecko) expose the browser debugging ports
* `profilePath`, `browserPid`, and `extensionId` are stamped by the browser launcher after launch
The full schema, error states, and two-phase readiness rules live in [the ready.json contract](/docs/contracts/ready-json).
`events.ndjson` is scoped to the current run: starting a new run resets the file, and every entry is stamped with the run's `runId` (matching `ready.json`), so consumers never see events from a previous session interleaved with the live one.
When a session misbehaves, run [`extension doctor`](/docs/commands/doctor): it walks the contract, control channel, token, executor, and browser in order and names the first failing leg with a fix.
### Stop the browser from launching
Two flags sound alike and do different things:
| You want | Flag | What happens |
| ----------------------------------------------- | -------------- | ---------------------------------------------------------------------------- |
| No browser at all | `--no-browser` | No browser process starts. The dev server runs and keeps rebuilding on save. |
| A browser, but no tab opened for your extension | `--no-open` | The browser starts and loads your extension. No tab is opened for it. |
```bash theme={null}
# Stop the browser launch. The dev server keeps running.
extension dev --no-browser
# Launch the browser without opening a tab for the extension.
extension dev --no-open
```
`--no-browser` is the run mode for headless, CI, and remote work, and it is the flag the [Playwright end-to-end workflow](/docs/workflows/playwright-e2e) is built on. It also has a config form, `commands.dev.noBrowser: true`, and an environment form, `EXTENSION_CLI_NO_BROWSER=1`.
`--no-open` is a launch detail. Reach for it when you want the browser open on whatever it already shows, without Extension.js opening a tab for your extension over it. `dev`, `start`, and `preview` all accept both flags.
### `--no-browser` and readiness synchronization
`--no-browser` disables browser launch but keeps the full dev loop. The dev server still watches your files, and on each rebuild it broadcasts a reload over the control bridge to the extension's service worker so your changes apply without a launched browser driving them:
* A **content-script** change is re-injected into the already-open matching tabs in place (the service worker runs `chrome.scripting.executeScript` with the fresh build), so the page updates on save without a manual refresh. Tabs opened afterward get the new build too, because the service worker re-registers the content scripts dynamically (`chrome.scripting.registerContentScripts`).
* A **service-worker / manifest** change restarts the extension.
So `--no-browser` behaves like a normal `dev` session for headless, continuous integration (CI), and remote/dev-container workflows: load the built `dist/` into any browser you control and it keeps updating on save. (Use `--no-reload` for a static dev bundle that never reloads. See below.)
`--no-browser` does not block external runners until the compile finishes.
For Playwright/CI/AI workflows:
1. Run `extension dev --no-browser` as a long-lived process.
2. Run `extension dev --wait --browser=` as the readiness gate.
3. Launch external browser automation only after `status: "ready"`.
`--wait` targets a second process (or CI step) and exits non-zero on `error`/timeout.
When `--wait` sees a stale `ready.json` from a dead process (`pid` no longer alive), it keeps waiting for a live producer.
`--wait` requires a local project path. Passing a remote URL exits with `E_ARGS`.
If you pass both `--wait` and `--no-browser` in the same command invocation, `--wait` takes precedence. The command runs in wait-only mode.
### Machine output with `--output json`
`--output json` prints a schema-1 envelope on stdout:
* A plain `dev` run prints one `status: "started"` frame as the first stdout line. It carries the project path, browser list, requested port, and the dev server `pid`. `dev` never terminates on its own, so no result frame follows, and unlike `build` a long-running session keeps writing its human progress lines to stdout after that frame. Parse the first line, then read `ready.json` for the live state.
* A `dev --wait` run prints one `status: "ready"` frame on success. Its `value.results` array carries the full ready contract per browser.
* Failures print one `ok: false` frame with an `error.code` (for example `E_READY_TIMEOUT`) before the process exits `1`.
### `--no-reload` for a clean dev bundle
`--no-reload` skips the content-script reinjection wrapper and the on-rebuild reload dispatch. The dev `dist` stays close to a production bundle and an open tab is not disturbed when files change. Reload the extension or page yourself to pick up changes.
`--no-reload` is only supported on `extension dev`. Passing it to `start`, `preview`, or `build` exits with an error. Internally it sets `EXTENSION_NO_RELOAD=true` so the develop process can read it from outside the CLI argv.
Dev builds emit `cheap-module-source-map` files that describe your source: the original TypeScript and the exact lines, for content scripts, classic multi-file groups, the background, and pages on both manifest versions. No `eval` variant is used, so the bundle runs under your own CSP.
## Logging flags
These flags are experimental and may change between minor releases.
| Flag | What it does | Default |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------ |
| `--logs ` | Minimum log level. | `off` |
| `--log-context ` | Context filter (`background`, `content`, `page`, `sidebar`, `popup`, `options`, `devtools`). | `all` |
| `--log-format ` | Logger output format. | `pretty` |
| `--no-log-timestamps` | Disable timestamps in pretty mode. | timestamps enabled |
| `--no-log-color` | Disable color in pretty mode. | color enabled |
| `--log-url ` | Filter log events by URL substring/regex. | unset |
| `--log-tab ` | Filter log events by tab ID. | unset |
## Shared global options
Also supports [Global flags](/docs/workflows/global-flags).
## Monorepo and workspace roots
You can point `dev` (and `build`) at the **root of a monorepo** instead of the extension package itself. Extension.js detects the workspace root and auto-resolves the extension package inside it:
```bash theme={null}
extension dev . # run from the monorepo root
```
When exactly one extension package is found, Extension.js resolves it and prints:
```text theme={null}
Workspace root detected — resolved extension package: packages/my-extension
```
When several candidates exist, it lists them so you can point at the one you mean:
```bash theme={null}
extension dev packages/my-extension
```
## Examples
### Running a local extension
```bash npm theme={null}
extension dev ./my-extension
```
```bash pnpm theme={null}
extension dev ./my-extension
```
```bash yarn theme={null}
extension dev ./my-extension
```
```bash bun theme={null}
extension dev ./my-extension
```
```bash deno theme={null}
extension dev ./my-extension
```
### Running a remote extension from GitHub
Pass a GitHub tree URL as the argument to develop a remote extension locally:
```bash npm theme={null}
extension dev https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/functional-samples/sample.page-redder
```
```bash pnpm theme={null}
extension dev https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/functional-samples/sample.page-redder
```
```bash yarn theme={null}
extension dev https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/functional-samples/sample.page-redder
```
```bash bun theme={null}
extension dev https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/functional-samples/sample.page-redder
```
```bash deno theme={null}
extension dev https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/functional-samples/sample.page-redder
```
### Running in Firefox
```bash npm theme={null}
extension dev ./my-extension --browser firefox
```
```bash pnpm theme={null}
extension dev ./my-extension --browser firefox
```
```bash yarn theme={null}
extension dev ./my-extension --browser firefox
```
```bash bun theme={null}
extension dev ./my-extension --browser firefox
```
```bash deno theme={null}
extension dev ./my-extension --browser firefox
```
### Running in multiple browsers in sequence
```bash npm theme={null}
extension dev ./my-extension --browser=chrome,firefox
```
```bash pnpm theme={null}
extension dev ./my-extension --browser=chrome,firefox
```
```bash yarn theme={null}
extension dev ./my-extension --browser=chrome,firefox
```
```bash bun theme={null}
extension dev ./my-extension --browser=chrome,firefox
```
```bash deno theme={null}
extension dev ./my-extension --browser=chrome,firefox
```
### Running inside Docker or a dev container
When you run inside Docker, dev containers, or GitHub Codespaces, bind the dev server to `0.0.0.0` so the host machine can reach it:
```bash npm theme={null}
extension dev ./my-extension --host 0.0.0.0
```
```bash pnpm theme={null}
extension dev ./my-extension --host 0.0.0.0
```
```bash yarn theme={null}
extension dev ./my-extension --host 0.0.0.0
```
```bash bun theme={null}
extension dev ./my-extension --host 0.0.0.0
```
```bash deno theme={null}
extension dev ./my-extension --host 0.0.0.0
```
Combine with `--port 0` to let the OS choose an available port automatically:
```bash npm theme={null}
extension dev ./my-extension --host 0.0.0.0 --port 0
```
```bash pnpm theme={null}
extension dev ./my-extension --host 0.0.0.0 --port 0
```
```bash yarn theme={null}
extension dev ./my-extension --host 0.0.0.0 --port 0
```
```bash bun theme={null}
extension dev ./my-extension --host 0.0.0.0 --port 0
```
```bash deno theme={null}
extension dev ./my-extension --host 0.0.0.0 --port 0
```
#### Bind host vs. connectable host
`--host` is the address the dev server **binds** to. The browser (the HMR client and the reload bridge) needs an address it can actually **connect** to, which is not always the same value:
* `--host 0.0.0.0` binds every interface, but `0.0.0.0` is not a connectable address. Extension.js automatically advertises `127.0.0.1` to the browser instead, the right target for the common port-forwarded Docker/dev-container/Codespaces setup, where the browser runs on the host and the port is forwarded to the container.
* For a true **remote** setup (the browser runs on a different machine than the dev server), pass `--public-host` with the address the browser can reach (an LAN IP or hostname). It is propagated to the HMR client URL, `ready.json`, and the reload bridge baked into the extension.
```bash npm theme={null}
# Browser on another machine reaches the dev server at devbox.local
extension dev ./my-extension --host 0.0.0.0 --public-host devbox.local
```
```bash pnpm theme={null}
extension dev ./my-extension --host 0.0.0.0 --public-host devbox.local
```
```bash yarn theme={null}
extension dev ./my-extension --host 0.0.0.0 --public-host devbox.local
```
```bash bun theme={null}
extension dev ./my-extension --host 0.0.0.0 --public-host devbox.local
```
When `--host` is a concrete address already (for example `--host 192.168.1.50`), that value is connectable as-is and is used directly. `--public-host` is only needed when the bind host and the browser-facing host differ.
### Running in Brave as a custom binary
```bash npm theme={null}
extension dev ./my-extension --chromium-binary /path/to/brave
```
```bash pnpm theme={null}
extension dev ./my-extension --chromium-binary /path/to/brave
```
```bash yarn theme={null}
extension dev ./my-extension --chromium-binary /path/to/brave
```
```bash bun theme={null}
extension dev ./my-extension --chromium-binary /path/to/brave
```
```bash deno theme={null}
extension dev ./my-extension --chromium-binary /path/to/brave
```
## Best practices
* **Browser compatibility:** Test your extension in different browsers to verify it works on every target.
* **Polyfilling:** The polyfill is on by default in `dev`, so `browser.*` calls work in Chromium-based browsers. Pass `--no-polyfill` when you want the raw bundle.
* **Automation reliability:** Treat `dev` as the watch-mode companion (`--no-browser` + `dev --wait`). Treat `start` as the production companion (`--no-browser` + `start --wait`). Use `--output=json` for scripts and CI automation.
## Next steps
* Build production artifacts with [`build`](/docs/commands/build).
* Validate production launch flow with [`start`](/docs/commands/start).
* Review browser targeting with [Browser-specific manifest fields](/docs/features/browser-specific-fields).
* Configure shared defaults in [`extension.config.js`](/docs/features/extension-configuration).
* Review configuration env loading behavior in [Environment variables](/docs/features/environment-variables#how-it-works).
# Doctor command for diagnosing dev sessions
Source: https://extension.js.org/docs/commands/doctor
Diagnose a running Extension.js dev session with the doctor command. Walks the ready contract, control channel, token, executor, and browser checks in order.
Use `doctor` to find out why a dev session (or an automation verb talking to it) is not working.
`doctor` walks the control-channel legs of a dev session in dependency order (from the on-disk readiness contract all the way to a live probe of the in-extension executor) and names the **first failing leg** with a concrete fix, instead of the dead-end error each command gives on its own.
## When to use `doctor`
* `extension logs` or an act verb can't connect and you want to know which leg is broken.
* An agent or script drives a session via `ready.json` and needs a machine-readable health check.
* A dev session looks alive but the extension stopped responding.
## Usage
```bash npm theme={null}
extension doctor [project-path] [options]
```
```bash pnpm theme={null}
extension doctor [project-path] [options]
```
```bash yarn theme={null}
extension doctor [project-path] [options]
```
```bash bun theme={null}
extension doctor [project-path] [options]
```
```bash deno theme={null}
extension doctor [project-path] [options]
```
If you omit the path, Extension.js diagnoses the session for the current working folder.
## Arguments and flags
| Flag | What it does | Default |
| ------------------------- | -------------------------------------------------------------------- | -------------------- |
| `[project-path]` | Path to the extension project root. | `process.cwd()` |
| `--browser ` | Which session to diagnose (`chrome`, `chromium`, `edge`, `firefox`). | resolved (see below) |
| `--output ` | Output format (`json` prints a schema-1 envelope for agents). | `pretty` |
### How `doctor` picks the session
Without `--browser`, `doctor` diagnoses the session that exists, not a hardcoded default. It lists the ready contracts under `dist/extension-js/` and resolves from there:
* Exactly one live contract wins outright.
* With several contracts, `chromium` wins when present, otherwise the first one alphabetically. A `session-resolution` warn check then names every candidate before the numbered checks run.
* With no contracts at all, `doctor` falls back to `chromium`.
## What it checks
Checks run in dependency order. When a leg fails, later checks that depend on it are marked **skip** and name the check that blocked them: a skip is not a pass.
A zeroth `session-resolution` check appears only when several live sessions exist. It warns and names the session that the run diagnoses.
| # | Check | What it verifies |
| - | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `ready-contract` | `dist/extension-js//ready.json` exists and reports `status: "ready"`. |
| 2 | `server-process` | The dev-server `pid` from the contract is still alive (a dead pid means the contract is stale). |
| 3 | `port-agreement` | The persisted control port matches the live contract's `controlPort` (a mismatch strands cached service workers on the old port). |
| 4 | `control-channel` | The control server accepts a connection on the contract port for this `instanceId`. |
| 5 | `eval-token` | When the session runs with `--allow-eval`, the session token is readable from the project root. |
| 6 | `executor` | A live probe round-trips through the extension's background context. |
| 7 | `browser` | The launched browser has not exited out from under the still-running dev server. The verdict also names the binary that ran and how it was chosen (`managed`, `pinned`, `system`, or `snapshot`), which is the fastest way to catch a session using a different browser than you assumed. |
Some legs have deliberate soft answers:
* `port-agreement` passes when no port file exists yet. A first session for this project and browser has nothing to disagree with.
* `executor` reports **warn**, not fail, within 10 seconds of a fresh compile. The service worker may still be attaching. Wait for `runtime: "attached"` in `ready.json` before acting.
* `browser` reports **skip** when the contract has no `cdpPort` stamped and no exit recorded. Absence of exit evidence is not proof of a live browser.
The pretty output prints one line per check plus the first failing check's remediation. Each status has a glyph: `✓` pass, `✗` fail, `!` warn, `–` skip. The exit code is `0` when nothing failed and `1` when any check failed, so CI and scripts can gate on it directly.
## Machine-readable output
`--output json` prints one schema-1 envelope. The check results ride in `value` on both verdicts, because an unhealthy report is still a report:
```json theme={null}
{
"schema": 1,
"ok": false,
"command": "doctor",
"status": "unhealthy",
"value": [
{
"check": "ready-contract",
"status": "pass",
"detail": "status ready, controlPort 43021, instanceId a1b2c3"
},
{
"check": "server-process",
"status": "fail",
"detail": "dev-server pid 4242 is dead, ready.json is stale",
"remediation": "A previous dev session died uncleanly; restart it: extension dev --browser=chromium --allow-control"
}
],
"error": {
"code": "E_SESSION_NOT_FOUND",
"message": "1 of 7 doctor checks failed."
},
"hint": "A previous dev session died uncleanly; restart it: extension dev --browser=chromium --allow-control",
"warnings": []
}
```
A healthy run answers `ok: true` with `status: "healthy"` and `error: null`.
`status` on each check is `pass`, `fail`, `warn`, or `skip`. `remediation` is present on failures that have a known fix. The envelope's `error.code` maps from the first failing check:
| First failing check | `error.code` |
| ----------------------------------------------- | ----------------------- |
| `ready-contract`, `server-process` | `E_SESSION_NOT_FOUND` |
| `port-agreement`, `control-channel`, `executor` | `E_CONTROL_UNAVAILABLE` |
| `eval-token` | `E_TOKEN_MISSING` |
| `browser` | `E_BROWSER_LAUNCH` |
## Typical flow
1. Start a session: `extension dev --browser=chromium --allow-control` (add `--allow-eval` for the eval verb).
2. When a follow-up command can't reach it, run `extension doctor` from the same project root.
3. Apply the remediation printed for the first failing check, then re-run `doctor` to confirm.
## Next steps
* Read the readiness contract `doctor` starts from in [`dev`](/docs/commands/dev#automation-metadata-recommended-for-scriptsagents).
* Stream extension logs from a healthy session with [Debugging](/docs/debugging).
* Review shared flags in [Global flags](/docs/workflows/global-flags).
# Eval command for running code in extension contexts
Source: https://extension.js.org/docs/commands/eval
Run a JavaScript expression inside any context of a running Extension.js dev session, from the background worker to a live page, straight from the terminal.
Run a JavaScript expression inside a context of a running dev session and print the result.
`eval` is the most powerful automation verb, so it is double locked. The session must be started with `extension dev --allow-eval`, and the CLI must present the session token that the dev server wrote for this project and browser. Both happen automatically when you run the two commands from the same project root.
## When to use `eval`
* You want to poke at extension state (`chrome.runtime`, storage, DOM) without opening DevTools.
* An agent needs to run a check inside the page and get a structured value back.
* You want a scriptable REPL against the background worker or a live tab.
## Usage
```bash npm theme={null}
extension eval [project-path] [options]
```
```bash pnpm theme={null}
extension eval [project-path] [options]
```
```bash yarn theme={null}
extension eval [project-path] [options]
```
```bash bun theme={null}
extension eval [project-path] [options]
```
```bash deno theme={null}
extension eval [project-path] [options]
```
For example, read the title and URL of the active tab from inside the page:
```bash theme={null}
extension eval "({title: document.title, url: location.href})" --context page
```
## Arguments and flags
| Flag | What it does | Default |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | --------------- |
| `` | The JavaScript expression to evaluate. | required |
| `[project-path]` | Path to the extension project root. | `process.cwd()` |
| `--context ` | Target context: `background`, `popup`, `options`, `sidebar`, `devtools`, `newtab`, `history`, `bookmarks`, `content`, `page`. | `background` |
| `--url ` | For `content`/`page`: the document to target, resolved to its tab. | unset |
| `--tab ` | For `content`/`page`: a specific tab. Without it, the `--url` match wins, else the active tab. | active tab |
| `--browser ` | Which session to target (`chrome`, `chromium`, `edge`, `firefox`). | `chromium` |
| `--timeout ` | Command timeout in milliseconds. | `5000` |
| `--output ` | Output format (`json` wraps the result in the schema-1 envelope). | `pretty` |
Extension pages (`popup`, `options`, `sidebar`, `devtools`, `newtab`, `history`, `bookmarks`) answer through their own in-page relay, so the surface must be open in the browser. Use `open` (documented in [Trigger actions and commands](/docs/debugging/trigger-actions-and-commands)) to open one first.
Pretty output prints the value itself: strings as-is, everything else as indented JSON. The envelope shape appears only under `--output json` (see [Result envelope](/docs/contracts/result-envelope)).
## How the unlock works
1. `extension dev --allow-eval` starts the session with eval enabled. `--allow-eval` also unlocks the other control verbs, so you do not need both flags.
2. The dev server writes a random session token to `.extension-js/control-token-` with `0600` permissions. The token is keyed per browser, so concurrent sessions do not collide.
3. `eval` reads that token from the project root and presents it during the handshake.
A session started without `--allow-eval` refuses the call, and the refusal names `--allow-eval` as the flag to add. A missing token fails with `E_TOKEN_MISSING`, a mismatched or disabled one with `E_EVAL_REFUSED`.
## Failure modes
* The expression threw inside the target: `E_EVAL`, with a hint that the expression itself is at fault. This is a result, not a transport error.
* No session for the browser: `E_SESSION_NOT_FOUND`, with the exact `extension dev` command to run.
* Target surface not open, or a stale `--tab` id: `E_TARGET_NOT_FOUND`. Use [`inspect --list-tabs`](/docs/commands/inspect#the-discovery-loop) to find live ids.
* The call outlived `--timeout`: `E_TIMEOUT`.
The exit code is `0` when the expression succeeds and `1` otherwise, so scripts can gate on it directly. Run [`doctor`](/docs/commands/doctor) when every verb fails and you want the broken leg named.
## Next steps
* Find tab ids and DOM state to target with [`inspect`](/docs/commands/inspect).
* Read or write `chrome.storage` without writing an expression with [`storage`](/docs/commands/storage).
* Read the wider debugging workflow in [Debugging](/docs/debugging).
* Review the machine output format in [Result envelope](/docs/contracts/result-envelope).
# Extension.js command guide
Source: https://extension.js.org/docs/commands/index
Reference for every Extension.js CLI command including dev, build, start, preview, create, install, and uninstall with usage examples.
Choose the right command for the current phase of extension development, from first scaffold to production validation.
## Command chooser
| Goal | Command | Example |
| ---------------------------------- | -------------- | ------------------------------------------------------------------ |
| Scaffold a new project | `create` | `npx extension@latest create my-extension --template=newtab-react` |
| Develop with watch mode | `dev` | `extension dev --browser=firefox` |
| Build production artifacts | `build` | `extension build --browser=chrome,firefox --zip` |
| Build and launch production output | `start` | `extension start --browser=edge` |
| Launch existing build output only | `preview` | `extension preview --browser=chrome` |
| Get a shareable URL for a project | `publish` | `extension publish --ttl=4` |
| Install a managed browser runtime | `install` | `extension install chrome` |
| Remove a managed browser runtime | `uninstall` | `extension uninstall chrome` |
| Diagnose a running dev session | `doctor` | `extension doctor --browser=chromium` |
| Read or stream session logs | `logs` | `extension logs --follow --context background,content` |
| List tabs or inspect a DOM | `inspect` | `extension inspect --tab 412 --with-console` |
| Run code in a live context | `eval` | `extension eval "location.href" --context page` |
| Read or write extension storage | `storage` | `extension storage get --key settings` |
| Reload a context on demand | `reload` | `extension reload --context background` |
| Open a surface or replay an action | `open` | `extension open popup` |
| Handshake for scripts and agents | `capabilities` | `extension capabilities` |
Running a bare `extension` with no command prints the full help and exits with code `0`.
## Typical developer flow
1. `create` to scaffold.
2. `dev` for iterative coding and reload cycles.
3. `build` for production artifacts.
4. `preview` or `start` for production-like validation.
5. `install` or `uninstall` when you need to manage Extension.js browser runtimes explicitly.
6. `logs`, `inspect`, `eval`, `storage`, `reload`, and `open` to observe and drive the running session.
7. `doctor` when a dev session or an automation verb talking to it stops responding.
8. `capabilities` first, when a script or agent drives the CLI instead of a human.
## Command references
* [create](/docs/commands/create)
* [dev](/docs/commands/dev)
* [build](/docs/commands/build)
* [start](/docs/commands/start)
* [preview](/docs/commands/preview)
* [publish](/docs/commands/publish)
* [install](/docs/commands/install)
* [uninstall](/docs/commands/uninstall)
* [doctor](/docs/commands/doctor)
* [logs](/docs/commands/logs)
* [inspect](/docs/commands/inspect)
* [eval](/docs/commands/eval)
* [storage](/docs/commands/storage)
* [reload](/docs/commands/reload)
* [open](/docs/debugging/trigger-actions-and-commands)
* [capabilities](/docs/commands/capabilities)
The `telemetry` command (`enable` / `disable` / `status`) manages anonymous telemetry consent. See [Telemetry and privacy](/docs/features/telemetry-and-privacy).
## Next steps
* Apply cross-command controls in [Global flags](/docs/workflows/global-flags).
* Keep defaults centralized in [Extension configuration](/docs/features/extension-configuration).
# Inspect command for DOM and tab discovery
Source: https://extension.js.org/docs/commands/inspect
Inspect a page or content DOM from the terminal with the Extension.js inspect command. List tabs, fetch HTML summaries, and attach recent console lines.
Inspect a page or content DOM through the agent bridge, without CDP and without opening DevTools.
`inspect` asks a running dev session what a document looks like right now. It can list open tabs, return a structural summary or raw HTML, and attach the last console lines for the same target.
The session must run with the control channel unlocked: start it with `extension dev --allow-control`. A refusal names the missing flag, so a denied call tells you exactly how to restart the session.
## When to use `inspect`
* You want to confirm what your content script actually rendered into a page.
* An agent needs tab ids to target [`eval`](/docs/commands/eval) or `inspect` calls precisely.
* You want DOM state and the matching console tail in one machine-readable result.
## Usage
```bash npm theme={null}
extension inspect [project-path] [options]
```
```bash pnpm theme={null}
extension inspect [project-path] [options]
```
```bash yarn theme={null}
extension inspect [project-path] [options]
```
```bash bun theme={null}
extension inspect [project-path] [options]
```
```bash deno theme={null}
extension inspect [project-path] [options]
```
## Arguments and flags
| Flag | What it does | Default |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| `[project-path]` | Path to the extension project root. | `process.cwd()` |
| `--context ` | What to inspect: `content`, `page`, or an open surface (`popup`, `options`, `sidebar`, `devtools`, `newtab`, `history`, `bookmarks`). | `content` |
| `--url ` | For `content`/`page`: the document to target, resolved to its tab. | unset |
| `--tab ` | For `content`/`page`: a specific tab. Without it, the `--url` match wins, else the active tab. | active tab |
| `--list-tabs` | List open tabs as `{id, url, title, active, windowId}` and exit. | off |
| `--include ` | Comma-separated result parts: `html`, `summary`. | `summary` |
| `--max-bytes ` | Cap on returned HTML bytes. | `262144` |
| `--with-console [n]` | Also include the last `n` console lines for the target. | `20` when set |
| `--browser ` | Which session to target (`chrome`, `chromium`, `edge`, `firefox`). | `chromium` |
| `--timeout ` | Command timeout in milliseconds. | `5000` |
| `--output ` | Output format (`json` wraps the result in the schema-1 envelope). | `pretty` |
There is no `--deep-dom` flag. Deep DOM support is a bridge capability the session reports internally, not something you toggle per call.
## The discovery loop
Numeric tab ids make targeting deterministic. The intended flow is list first, then inspect:
```bash theme={null}
extension inspect --list-tabs
extension inspect --tab 412 --with-console
```
`--list-tabs` needs the unlocked control channel but no eval token, so it works in any `--allow-control` session. The result is an array of `{id, url, title, active, windowId}` objects. Feed the `id` value straight into `--tab` here or in `eval`.
## Console augmentation
`--with-console` merges a `console` array into the result: the last `n` log records for the same context and tab, read from the session's `logs.ndjson`. This gives an agent DOM state and console evidence in a single round trip. The augmentation is best effort and never fails the command.
Both output modes show it. Pretty mode prints the console lines after the DOM summary in the same `[seq] LEVEL (context) message` format `logs` uses, or `(no console lines captured)` when the tail is empty. `--output json` carries the raw records in the envelope's `console` array.
## Truncation
HTML larger than `--max-bytes` comes back truncated. Pretty mode prints a truncation notice on stderr, and `--output json` sets `truncated: true` in the envelope (see [Result envelope](/docs/contracts/result-envelope)).
## Failure modes
* No session found for the browser: `E_SESSION_NOT_FOUND`, with the exact `extension dev` command to run.
* Session running without `--allow-control`: the connection is refused and the error names the flag.
* Target surface not open, or a `--tab` id that no longer exists: `E_TARGET_NOT_FOUND`.
* The target took longer than `--timeout`: `E_TIMEOUT`.
Run [`doctor`](/docs/commands/doctor) when the same session refuses every verb and you want the first broken leg named.
## Next steps
* Run an expression in the page that you just inspected with [`eval`](/docs/commands/eval).
* Stream the same session's logs continuously with [`logs`](/docs/commands/logs).
* Read the wider debugging workflow in [Debugging](/docs/debugging).
# Install command for managed browser runtimes
Source: https://extension.js.org/docs/commands/install
Add a managed browser runtime to the Extension.js cache for deterministic builds. Supports Chrome for Testing, Chromium, Firefox, and Edge.
Use `install` to add a managed browser runtime into the Extension.js cache.
This is most useful when you want a consistent browser binary for `dev`, `build`, `start`, or `preview`. It supports Chrome for Testing, Chromium, Firefox, and Edge.
## When to use `install`
* You need a consistent, repeatable browser binary for continuous integration (CI), automation, or team-consistent local runs.
* You want Chrome for Testing instead of relying on whatever Chrome version your system has installed.
* You are setting up cross-browser testing with managed Firefox or Edge runtimes.
## Why Extension.js downloads a browser
The run commands prefer a managed browser runtime over the browser that you use every day.
* The managed binary is version-pinned. `dev`, CI runs, and teammates all launch the same build.
* Chrome for Testing is built for automation. Recent branded Chrome builds (150+) can drop the `--load-extension` switch that loads your extension.
* The managed runtime runs in an isolated profile. It never touches your personal browser, its profile, or its settings.
You do not have to run `install` up front. When no usable binary exists for the requested target, the run commands print the exact install command.
## Skip the managed download
You can develop against a browser that is already installed, with no download:
* Run a fork by name: `extension dev --browser=brave` locates the installed Brave for you. See [Running other browsers](/docs/browsers/running-other-browsers).
* Pin any binary: pass `--chromium-binary ` or `--gecko-binary ` to `dev`, `start`, or `preview`. The pin overrides every locator.
* Requested `edge` launches the Edge that is installed on your system when no managed Edge exists.
* Requested `chrome` refuses a branded system Chrome and asks for Chrome for Testing instead.
* To run branded Chrome anyway, pin it with `--chromium-binary`.
### When Edge is already installed
`--browser=edge` finds the system Edge on its own, so you can skip `extension install edge`. Run `extension install edge` only when you want a pinned managed copy for automation.
On Linux, the managed Edge download needs an interactive session with sudo rights. When that download fails and a system Edge exists, the installer reports the system binary and succeeds with it.
## Canonical usage
For a single browser, use the positional form:
```bash npm theme={null}
extension install
```
```bash pnpm theme={null}
extension install
```
```bash yarn theme={null}
extension install
```
```bash bun theme={null}
extension install
```
```bash deno theme={null}
extension install
```
Use `--browser` only when you need multiple targets, browser families, or `all`.
## Install command capabilities
| Capability | What it gives you |
| --------------------- | -------------------------------------------------------------------------- |
| Managed browser cache | Stable install location under the Extension.js browser cache |
| Repeatable runtime | Consistent binaries for repeatable local runs and automation |
| Cross-browser setup | One command flow for Chrome, Chromium, Edge, and Firefox |
| Path discovery | `--where` reveals the resolved cache root or browser-specific install path |
## Usage
```bash npm theme={null}
extension install [browser-name] [options]
```
```bash pnpm theme={null}
extension install [browser-name] [options]
```
```bash yarn theme={null}
extension install [browser-name] [options]
```
```bash bun theme={null}
extension install [browser-name] [options]
```
```bash deno theme={null}
extension install [browser-name] [options]
```
## Arguments and flags
| Flag / argument | What it does | Default |
| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------- |
| `[browser-name]` | Install a single browser target such as `chrome`, `chromium`, `edge`, or `firefox` | `chromium` |
| `--browser ` | Override the positional browser name and support multi-target installs | unset |
| `--where` | Print the resolved managed cache root, or browser-specific install path | disabled |
| `--output ` | Result format. `json` prints a schema-1 envelope on stdout | `pretty` |
### What `all` means here
`extension install --browser all` installs `chrome`, `chromium`, `edge`, and `firefox`. This differs from `--browser all` on the run commands, which expands to `chrome`, `edge`, and `firefox` only. The install set also covers Chromium because it is the default launch target for `dev` and `start`.
## Machine output with `--output json`
`--output json` prints one schema-1 envelope on stdout:
* A successful install prints a `status: "installed"` frame with the installed browsers in `value.browsers`.
* `--where` prints a `status: "located"` frame with the resolved paths in `value.paths`.
* A failed download prints `ok: false` with `error.code: "E_BROWSER_DOWNLOAD"` before the process exits `1`.
## Examples
### Install Chrome for Testing
```bash theme={null}
extension install chrome
```
### Install multiple targets in one command
```bash theme={null}
extension install --browser chrome,firefox
```
### Show the managed install path for Chrome
```bash theme={null}
extension install chrome --where
```
## Cache locations
By default, Extension.js stores managed browsers in a stable per-user cache:
* macOS: `~/Library/Caches/extension.js/browsers`
* Linux: `~/.cache/extension.js/browsers` or `$XDG_CACHE_HOME/extension.js/browsers`
* Windows: `%LOCALAPPDATA%\extension.js\browsers`
You can override the cache root with `EXT_BROWSERS_CACHE_DIR`.
### What the cache holds
Each browser gets its own folder under the cache root, but the layout inside differs by download engine:
* `chrome`, `chromium`, and `firefox` come from `@puppeteer/browsers`. Expect its nested platform and version folders inside each browser directory.
* `edge` comes from `playwright install msedge`, which lays the binary out in its own structure.
* `safari` has no download. Safari ships with macOS and needs the full Xcode app for builds, so `extension install safari` refuses with an explanation.
* Named forks such as Brave are never downloaded. Point at them with `--chromium-binary` or `--gecko-binary` instead.
Use `--where` instead of hardcoding paths, because the nested layout can change with the download engines.
### Project-local binaries
Set `EXTENSIONJS_BINARIES_IN_DIST=1` to make the run commands resolve managed binaries under `dist/extension-js/binaries` inside the project instead of the shared per-user cache. This suits sandboxed or fully self-contained project setups.
## Best practices
* **Use `install` in CI** to pin a consistent browser binary instead of relying on whatever the runner provides.
* **Prefer `chrome`** over `chromium` for Chrome for Testing: it matches stable Chrome behavior more closely.
* **Use `--where`** to verify cache paths before scripting automation around managed browsers.
* `install` only manages browsers inside the Extension.js cache. It does not modify system browser installs.
## Behavior notes
* `chrome` installs Chrome for Testing rather than relying on the system Google Chrome app.
* `edge` may require a privileged interactive session on Linux.
## Next steps
* Remove managed browsers with [`uninstall`](/docs/commands/uninstall).
* Use managed browsers with [`dev`](/docs/commands/dev) and [`start`](/docs/commands/start).
* Learn about [Running other browsers](/docs/browsers/running-other-browsers) with custom binary paths.
# Logs command for reading dev session output
Source: https://extension.js.org/docs/commands/logs
Print or stream logs from every context of a running Extension.js dev session. Filter by context, level, URL, or tab, and pipe ndjson to tools.
Print or stream logs from every context of a running dev session.
`logs` reads the log records that a [`dev`](/docs/commands/dev) session collects from your extension: background, content scripts, popup, options, and the rest. One command shows them all, merged in order, without opening a single DevTools window.
## When to use `logs`
* You want console output from the background worker and a content script in one stream.
* An agent or script needs machine-readable log records instead of screen-scraped terminal text.
* You want to check what an extension logged earlier without re-triggering the behavior.
## Usage
```bash npm theme={null}
extension logs [project-path] [options]
```
```bash pnpm theme={null}
extension logs [project-path] [options]
```
```bash yarn theme={null}
extension logs [project-path] [options]
```
```bash bun theme={null}
extension logs [project-path] [options]
```
```bash deno theme={null}
extension logs [project-path] [options]
```
By default, `logs` prints the records already on disk and exits. Add `--follow` to stay attached and stream new records live.
## Arguments and flags
| Flag | What it does | Default |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| `[project-path]` | Path to the extension project root. | `process.cwd()` |
| `--browser ` | Which session to read (`chrome`, `chromium`, `edge`, `firefox`). | `chromium` |
| `--follow` | Stream live over the control channel instead of printing and exiting. | off |
| `--context ` | Comma-separated contexts (`background`, `content`, `popup`, `options`, `sidebar`, `devtools`, `page`). | all contexts |
| `--level ` | Minimum severity (`off`, `error`, `warn`, `info`, `debug`, `trace`, `all`). | `all` |
| `--signals-only` | Experimental. Show only structured `dx.signal` diagnostics. No emitter ships yet, so this currently prints nothing. | off |
| `--since ` | Only show events after this sequence number, or after this ISO timestamp by the event clock. | unset |
| `--url ` | Only events whose URL or hostname matches (glob with `*`, or a plain substring). | unset |
| `--tab ` | Only events from this tab id. | unset |
| `--output ` | Output format. | `pretty` on a TTY, `ndjson` when piped |
Choosing a `--level` includes that level plus everything more severe. `--level warn` shows `warn` and `error`. Plain `console.log` records count as `info`.
## One-shot mode
Without `--follow`, the command reads `dist/extension-js//logs.ndjson` directly and needs no live connection. The dev session appends every record to that file, so a one-shot read works even after you close the browser.
If the file does not exist, `logs` prints a hint to start `extension dev` first and exits with code `1`. Machine formats also emit a failure envelope with code `E_LOGS_NOT_FOUND` (see [Result envelope](/docs/contracts/result-envelope)).
## Follow mode
`--follow` looks up the session's readiness contract, connects to the control channel as a log consumer, and streams records as they happen. It needs a running dev session, but no unlock flag: log consumption is always allowed.
```bash theme={null}
extension logs --follow --context background,content --level info
```
If the stream falls behind and the session drops records, `logs` prints a one-line gap notice on stderr with the drop count and reason.
When you stop the stream with `Ctrl+C`, machine formats print one terminating success envelope with status `interrupted`, so a consumer can tell a clean stop from a crash. The exit code is `0`.
If no session is found for the chosen browser, the command fails with `E_SESSION_NOT_FOUND` and names the `extension dev` command to run.
## Output formats
Pretty mode prints one line per record:
```plaintext theme={null}
[142] INFO (background) message text from the worker
[143] ERROR (content) E_SOMETHING failed to reach the page
↳ remediation hint, when the record carries one
```
`ndjson` prints one raw JSON record per line, ideal for `jq` and log shippers. `json` pretty-prints each record over multiple lines. When stdout is not a TTY, `ndjson` is already the default, so piping needs no extra flag:
```bash theme={null}
extension logs --context content | jq -r '.messageParts | join(" ")'
```
## Examples
Show only errors and warnings from content scripts on a specific site:
```bash theme={null}
extension logs --context content --level warn --url "*.example.com"
```
Resume reading after a known record, useful for polling agents:
```bash theme={null}
extension logs --since 142 --output ndjson
```
## Next steps
* Diagnose a session that `logs --follow` cannot reach with [`doctor`](/docs/commands/doctor).
* Grab a DOM snapshot with the recent console tail in one call with [`inspect`](/docs/commands/inspect).
* Read the wider debugging workflow in [Debugging](/docs/debugging).
* Review the machine failure format in [Result envelope](/docs/contracts/result-envelope).
# Preview command to launch built extensions
Source: https://extension.js.org/docs/commands/preview
Launch an already-built extension for production-like manual testing without recompiling. Load unpacked output and run the browser launcher flow.
Launch an already-built extension output for production-like manual testing.
`preview` does not compile your project. It loads an existing unpacked extension root and runs the browser launcher flow.
## When to use `preview`
* Running existing build output without rebuilding.
* Comparing packaged behavior across browser targets quickly.
* Debugging runtime issues tied to production artifacts rather than dev/watch mode.
## Preview command capabilities
| Capability | What it gives you |
| ----------------------- | ------------------------------------------------------ |
| Build-output validation | Test real production artifacts without rebuilding |
| Browser-target checks | Run compiled output against selected browser targets |
| Runner control | Launch or skip browser runner based on workflow needs |
| Fast manual QA | Verify packaging-ready behavior quickly before release |
> `preview` is run-only. It prefers `dist/` when that output exists. You can also point it at another unpacked extension folder that already contains a `manifest.json`.
## Usage
```bash npm theme={null}
extension preview [project-path] [options]
```
```bash pnpm theme={null}
extension preview [project-path] [options]
```
```bash yarn theme={null}
extension preview [project-path] [options]
```
```bash bun theme={null}
extension preview [project-path] [options]
```
```bash deno theme={null}
extension preview [project-path] [options]
```
If you omit the path, Extension.js uses the current working folder.
## How `preview` chooses what to run
`preview` checks these locations in order:
1. `--output-path ` when you pass it. It wins over everything else.
2. `dist/` for the selected browser target.
3. The provided project path or current working folder.
The folder needs to contain an unpacked extension with a `manifest.json`. It does not matter whether a build ran in the same command.
## Arguments and flags
| Flag | Alias | What it does | Default |
| --------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| `[path]` | - | Preview built extension from a project path. | `process.cwd()` |
| `--browser ` | - | Browser/engine target (`chromium`, `chrome`, `edge`, `firefox`, etc.). | `commands.preview.browser`, else `chromium` |
| `--profile ` | - | Browser profile path or boolean profile mode. | fresh profile |
| `--chromium-binary ` | - | [Custom Chromium-family binary path](/docs/browsers/running-other-browsers). | system default |
| `--gecko-binary ` | `--firefox-binary` | [Custom Gecko-family binary path](/docs/browsers/running-other-browsers). | system default |
| `--starting-url ` | - | Starting URL in launched browser. | unset |
| `--no-open` | - | Launch the browser, but do not open a tab for your extension. The browser still starts and loads it. See [stop the browser from launching](#stop-the-browser-from-launching). | a tab opens on launch |
| `--no-browser` | - | Stop the browser from launching. See [stop the browser from launching](#stop-the-browser-from-launching). | browser launch enabled |
| `--port ` | - | Runner/devtools port when runner is enabled. Use `0` for OS-assigned port. | `8080` |
| `--extensions ` | - | Comma-separated companion extensions or store URLs. | unset |
| `--output-path ` | - | Existing unpacked extension directory to run. | `dist/` when available |
| `--output ` | - | Result format. `json` prints a schema-1 envelope on stdout. | `pretty` |
| `--debug` | - | Enable maintainer diagnostics. | disabled |
`--author` and `--author-mode` are hidden, deprecated aliases for `--debug`.
## Browser support
`preview` has no Safari path. Passing `--browser safari` (or `webkit-based`) exits with `E_COMMAND_UNSUPPORTED_FOR_TARGET`. Safari is a supported browser, but this command cannot launch it. Use [`dev`](/docs/commands/dev) or [`build`](/docs/commands/build) for Safari targets.
## Remote URLs and light mode
When the path argument is a remote `http(s)` URL, `preview` sets `EXTJS_LIGHT=1` automatically. This runs the launch in light mode for downloaded extensions. Set `EXTJS_LIGHT` yourself beforehand to override this behavior.
## Stop the browser from launching
Two flags sound alike and do different things:
| You want | Flag | What happens |
| ----------------------------------------------- | -------------- | --------------------------------------------------------------------- |
| No browser at all | `--no-browser` | No browser process starts. `preview` writes its readiness metadata. |
| A browser, but no tab opened for your extension | `--no-open` | The browser starts and loads your extension. No tab is opened for it. |
```bash theme={null}
# Stop the browser launch.
extension preview --no-browser
# Launch the browser without opening a tab for the extension.
extension preview --no-open
```
`--no-browser` also has a config form, `commands.preview.noBrowser: true`, and an environment form, `EXTENSION_CLI_NO_BROWSER=1`. `dev`, `start`, and `preview` all accept both flags.
## Automation metadata
`preview` writes readiness metadata to:
* `dist/extension-js//ready.json`
For `--no-browser` flows, this provides deterministic command state:
* `starting` while command initializes
* `ready` when run-only validation is complete
* `error` when required output is missing or startup fails
* `runId` and `startedAt` for session correlation in scripts/agents
`preview` does not provide a `--wait` gate flag. For `preview` automation, consume `ready.json` directly.
### Machine output with `--output json`
`--output json` prints one schema-1 envelope on stdout:
* A successful run prints a `status: "ready"` frame. Its `value` carries the list of previewed browsers, plus `projectPath` when you passed a path argument.
* When there is nothing to preview, the frame is `ok: false` with `status: "not-found"` and `error.code: "E_PREVIEW_NO_DIST"`. Its hint says to run `extension build` first.
* Other failures print `ok: false` with `status: "failed"` before the process exits `1`.
## Logging flags
These flags are experimental and may change between minor releases.
| Flag | What it does | Default |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------ |
| `--logs ` | Minimum log level. | `off` |
| `--log-context ` | Context filter (`background`, `content`, `page`, `sidebar`, `popup`, `options`, `devtools`). | `all` |
| `--log-format ` | Logger output format. | `pretty` |
| `--no-log-timestamps` | Disable timestamps in pretty mode. | timestamps enabled |
| `--no-log-color` | Disable color in pretty mode. | color enabled |
| `--log-url ` | Filter log events by URL substring/regex. | unset |
| `--log-tab ` | Filter log events by tab ID. | unset |
## Shared global options
Also supports [Global flags](/docs/workflows/global-flags).
## Examples
### Previewing a local extension
```bash npm theme={null}
extension preview ./my-extension
```
```bash pnpm theme={null}
extension preview ./my-extension
```
```bash yarn theme={null}
extension preview ./my-extension
```
```bash bun theme={null}
extension preview ./my-extension
```
```bash deno theme={null}
extension preview ./my-extension
```
### Previewing in Edge and Chrome
```bash npm theme={null}
extension preview ./my-extension --browser=edge,chrome
```
```bash pnpm theme={null}
extension preview ./my-extension --browser=edge,chrome
```
```bash yarn theme={null}
extension preview ./my-extension --browser=edge,chrome
```
```bash bun theme={null}
extension preview ./my-extension --browser=edge,chrome
```
```bash deno theme={null}
extension preview ./my-extension --browser=edge,chrome
```
### Preview without launching the browser
```bash npm theme={null}
extension preview ./my-extension --no-browser
```
```bash pnpm theme={null}
extension preview ./my-extension --no-browser
```
```bash yarn theme={null}
extension preview ./my-extension --no-browser
```
```bash bun theme={null}
extension preview ./my-extension --no-browser
```
```bash deno theme={null}
extension preview ./my-extension --no-browser
```
## Behavior notes
* `preview` is run-only and never compiles the project.
* `preview` prefers existing build output (`dist/`) but can fall back to another unpacked extension root.
* `preview` does not run watch mode or hot module replacement (HMR).
* For scripts/agents, rely on `ready.json` and avoid parsing terminal output.
## Best practices
* Run `build` before `preview` when testing a fresh production artifact.
* Pass the project path argument when your unpacked extension lives outside the default project output.
* Use `--browser` to verify behavior across targets before packaging.
## Next steps
* Build and launch in one step with [`start`](/docs/commands/start).
* Generate production artifacts with [`build`](/docs/commands/build).
* Configure shared defaults in [`extension.config.js`](/docs/features/extension-configuration).
* Review configuration env loading behavior in [Environment variables](/docs/features/environment-variables#how-it-works).
# Publish command for shareable build URLs
Source: https://extension.js.org/docs/commands/publish
Turn a project on extension.dev into a shareable URL with the Extension.js publish command. Requires an access token and prints the share link.
Ask [extension.dev](https://docs.extension.dev?utm_source=extension-js-org\&utm_medium=sponsor\&utm_campaign=docs-seam) for a shareable URL to a project you already have there.
`publish` is a thin client. It does not compile, package, or upload anything. It sends one authenticated request to the platform and prints the URL the platform answers with.
## When to use `publish`
* Sending a reviewer a link to a build instead of a zip file.
* Wiring a share link into CI after `build` produced the artifacts.
* Pinning a share link to one specific build rather than the project's latest.
`publish` resolves a project that already exists on extension.dev, so it needs a build the platform recorded. To send someone the build sitting in your own `dist/` right now, upload that build instead: [Share an unpublished build for review](https://docs.extension.dev/share/unpublished-build-for-review?utm_source=extension-js-org\&utm_medium=sponsor\&utm_campaign=docs-seam).
`publish` talks to the extension.dev platform, which is a separate product
from Extension.js. The Extension.js commands that run on your machine
(`create`, `dev`, `build`, `preview`, `start`) never need an account.
`publish` does.
## Usage
```bash npm theme={null}
extension publish [project-path] [options]
```
```bash pnpm theme={null}
extension publish [project-path] [options]
```
```bash yarn theme={null}
extension publish [project-path] [options]
```
```bash bun theme={null}
extension publish [project-path] [options]
```
```bash deno theme={null}
extension publish [project-path] [options]
```
The project that gets published is whatever your token is scoped to. The path argument does not upload anything. It names the local directory whose project name the scope check below compares against.
## Token requirement
`publish` refuses to run without an access token. It looks in three places, in this order:
1. `--token ` on the command line.
2. `EXTENSION_DEV_TOKEN` in the environment (preferred for CI).
3. The stored device login that `npx @extension.dev/mcp login` writes.
Without any of them, the command exits with code `1` before any network call happens, and prints this to stderr:
```plaintext theme={null}
No token. Publishing needs an extension.dev access token.
Get one: https://docs.extension.dev/tools/publish
Pass --token, set EXTENSION_DEV_TOKEN, or run npx @extension.dev/mcp login.
```
Create a token from the extension.dev dashboard or the project access-tokens API, documented in [Access tokens](https://docs.extension.dev/tools/access-tokens?utm_source=extension-js-org\&utm_medium=sponsor\&utm_campaign=docs-seam).
## Scope checks
A stored device login is scoped to one project. Publishing from an unrelated directory would mint a share link for that project without naming it anywhere obvious. `publish` treats that mismatch as a refusal, not a warning:
* When the directory's project name does not match the stored login's project, the command refuses and names both.
* Pass `--project ` to publish the login's project on purpose from anywhere.
* Passing `--project` with a slug that does not match the stored login also refuses.
* A `--token` or `EXTENSION_DEV_TOKEN` token skips the stored-login comparison entirely.
The local project name comes from `package.json`, then `manifest.json`, then `src/manifest.json`, then the folder name.
## Arguments and flags
| Flag | What it does | Default |
| ------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------ |
| `[project-path]` | Directory whose project name the scope check reads. Not uploaded. | `process.cwd()` |
| `--token ` | extension.dev access token. | `EXTENSION_DEV_TOKEN`, then stored login |
| `--api ` | Platform base URL. Useful for self-hosted or staging endpoints. | `EXTENSION_DEV_API_URL`, then platform URL |
| `--ttl ` | Share-link lifetime in hours, from 1 to 168. Private projects only. | `24` |
| `--build-sha ` | Pin the share URL to a specific build instead of the latest. | latest build |
| `--project ` | Name the project this publish is for, when it is not the directory you are in. | unset |
| `--output ` | Output format. `json` prints the full platform response. | `pretty` |
## What it prints
Pretty output is a single line, the share URL, so it pipes cleanly:
```bash theme={null}
extension publish
# https://.extension.dev/
```
`--output json` prints one envelope. The platform response sits in `value`, and it carries more than the URL:
```json theme={null}
{
"schema": 1,
"ok": true,
"command": "publish",
"status": "published",
"value": {
"shareUrl": "https://.extension.dev/?share=",
"visibility": "private",
"token": "",
"expiresAt": "2026-01-01T00:00:00.000Z",
"ttlHours": 24,
"project": "",
"tokenSource": "stored-login"
},
"error": null,
"warnings": []
}
```
A public project answers with `value.shareUrl` and `value.visibility` only. There is no token to carry.
With `--build-sha`, the URL points at that build instead of the project overview: `https://.extension.dev//builds/`.
## Public and private projects
A project is either public or private. `publish` only reads that setting and never changes it. The platform decides what kind of link you get:
| Project visibility | What comes back |
| ------------------ | ------------------------------------------------------------------------------------- |
| Public | The project's own address, with no token. `--ttl` is ignored because nothing expires. |
| Private | That same address plus `?share=`, which stops working after `--ttl` hours. |
Both answers point at the same page. Visibility decides whether a token is attached, not which address you get.
## Pinning to a build
`--build-sha` links to one build instead of the project's latest. The platform verifies the sha against the project's build index and answers with a `404` and an `UNKNOWN_BUILD` code when no completed build matches, so a typo fails loudly instead of producing a link to the wrong artifact.
```bash npm theme={null}
extension publish --build-sha=9fceb02
```
```bash pnpm theme={null}
extension publish --build-sha=9fceb02
```
```bash yarn theme={null}
extension publish --build-sha=9fceb02
```
```bash bun theme={null}
extension publish --build-sha=9fceb02
```
```bash deno theme={null}
extension publish --build-sha=9fceb02
```
## Examples
### Publishing from CI
```bash theme={null}
EXTENSION_DEV_TOKEN=$EXTENSION_DEV_TOKEN extension build --browser=chrome --zip
SHARE_URL=$(EXTENSION_DEV_TOKEN=$EXTENSION_DEV_TOKEN extension publish)
echo "Review build: $SHARE_URL"
```
### A short-lived link for one reviewer
```bash npm theme={null}
extension publish --ttl=4
```
```bash pnpm theme={null}
extension publish --ttl=4
```
```bash yarn theme={null}
extension publish --ttl=4
```
```bash bun theme={null}
extension publish --ttl=4
```
```bash deno theme={null}
extension publish --ttl=4
```
## Behavior notes
* `publish` never compiles. Run [`build`](/docs/commands/build) first when you want the link to point at fresh output.
* Every failure path exits with code `1`: a missing token, an unreachable platform, or any non-2xx response, which is printed as `publish failed (): `.
* `--api` accepts a base URL with or without a trailing slash. The command appends `/api/cli/publish` itself.
* `--ttl` is clamped by the platform to the 1 to 168 hour range.
* The `?share=` token this command returns is not the 30 day revocable preview link. That link comes from a different verb, which uploads the build; `publish` uploads nothing. See [the platform's publish page](https://docs.extension.dev/tools/publish?utm_source=extension-js-org\&utm_medium=sponsor\&utm_campaign=docs-seam).
## Next steps
* Produce the artifacts to share with [`build`](/docs/commands/build).
* Validate the artifacts locally first with [`preview`](/docs/commands/preview).
* Hand someone an unpublished build behind a link with no zip and no install, in [Share an unpublished build for review](https://docs.extension.dev/share/unpublished-build-for-review?utm_source=extension-js-org\&utm_medium=sponsor\&utm_campaign=docs-seam).
* Read how builds are recorded in [Builds](https://docs.extension.dev/builds/overview?utm_source=extension-js-org\&utm_medium=sponsor\&utm_campaign=docs-seam).
# Reload command for restarting extension contexts
Source: https://extension.js.org/docs/commands/reload
Reload the background worker, a content script tab, or a page of a running Extension.js dev session on demand, from the terminal or a script.
Reload a running extension or tab on demand.
The [`dev`](/docs/commands/dev) session already reloads automatically when files change. `reload` is for the cases automatic reload cannot see: state you mutated by hand, a wedged service worker, or a test that needs a clean context between runs.
The session must run with the control channel unlocked: start it with `extension dev --allow-control`. A refusal names the missing flag.
## When to use `reload`
* The background worker holds bad in-memory state and you want a clean restart.
* A script seeded storage or triggered a flow and needs a fresh context afterward.
* You changed something outside the watcher's view and want to force a re-read.
## Usage
```bash npm theme={null}
extension reload [project-path] [options]
```
```bash pnpm theme={null}
extension reload [project-path] [options]
```
```bash yarn theme={null}
extension reload [project-path] [options]
```
```bash bun theme={null}
extension reload [project-path] [options]
```
```bash deno theme={null}
extension reload [project-path] [options]
```
## Arguments and flags
| Flag | What it does | Default |
| ------------------------- | ------------------------------------------------------------------ | --------------- |
| `[project-path]` | Path to the extension project root. | `process.cwd()` |
| `--context ` | What to reload: `background`, `content`, or `page`. | `background` |
| `--tab ` | For `content`/`page`: a specific tab. | active tab |
| `--browser ` | Which session to target (`chrome`, `chromium`, `edge`, `firefox`). | `chromium` |
| `--timeout ` | Command timeout in milliseconds. | `5000` |
| `--output ` | Output format (`json` wraps the result in the schema-1 envelope). | `pretty` |
## What each context reloads
* `background` restarts the extension itself, which restarts the service worker and re-reads the manifest.
* `content` reloads the tab that hosts the targeted content script, so the script re-injects.
* `page` reloads the targeted tab as a plain page reload.
Find numeric tab ids with [`inspect --list-tabs`](/docs/commands/inspect#the-discovery-loop) when the active tab is not the one you want.
## Failure modes
* No session for the browser: `E_SESSION_NOT_FOUND`, with the exact `extension dev --allow-control` command to run.
* Session running without `--allow-control`: the connection is refused and the error names the flag.
* A `--tab` id that no longer exists: `E_TARGET_NOT_FOUND`.
* The call outlived `--timeout`: `E_TIMEOUT`.
The exit code is `0` on success and `1` on any failure. Machine consumers should read the envelope from `--output json` (see [Result envelope](/docs/contracts/result-envelope)).
## Next steps
* Understand what automatic reload already covers in [Reload and HMR](/docs/features/reload-and-hmr).
* Verify the reloaded context came back clean with [`logs`](/docs/commands/logs) or [`inspect`](/docs/commands/inspect).
* Diagnose a session that refuses the call with [`doctor`](/docs/commands/doctor).
* Read the wider debugging workflow in [Debugging](/docs/debugging).
# Start command for build-and-launch workflow
Source: https://extension.js.org/docs/commands/start
Run a production build and immediately launch the extension in the browser with one command. Combines build and preview into a single step.
Use `start` when you want a production build and immediate browser launch in one command.
The `start` command runs a production build first, then launches the built extension using the same flow as the `preview` command.
## When to use `start`
* Manually validating production behavior right after compilation.
* Reproducing runtime differences between watch mode and production output.
* Running a production-like check locally without a separate `build` then `preview` step.
## Start command capabilities
| Capability | What it gives you |
| -------------------------- | --------------------------------------------------------- |
| Build + launch workflow | Run production compile and browser launch in one step |
| Target selection | Start directly in selected browser or engine target |
| Runner control | Skip browser launch when you only need build verification |
| Production-like validation | Check real compiled output instead of watch-mode state |
## How it differs from other commands
* `dev`: dev server + hot module replacement (HMR)/watch mode
* `build`: production build only
* `preview`: launch an existing built extension without building
* `start`: `build` + `preview` in sequence
## Usage
```bash npm theme={null}
extension start [path-or-url] [options]
```
```bash pnpm theme={null}
extension start [path-or-url] [options]
```
```bash yarn theme={null}
extension start [path-or-url] [options]
```
```bash bun theme={null}
extension start [path-or-url] [options]
```
```bash deno theme={null}
extension start [path-or-url] [options]
```
If you omit the path, the command uses the current working folder.
## Arguments and flags
| Flag | Alias | What it does | Default |
| --------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `[path or url]` | - | Extension path or remote URL. | `process.cwd()` |
| `--browser ` | - | Browser/engine target. | `commands.start.browser`, else `chromium` |
| `--profile ` | - | Browser profile path or boolean profile mode. | fresh profile |
| `--chromium-binary ` | - | Custom Chromium-family binary path. | system default |
| `--gecko-binary ` | `--firefox-binary` | Custom Gecko-family binary path. | system default |
| `--polyfill [boolean]` | - | Enable `browser.*` API compatibility polyfill for Chromium targets. | `true` |
| `--no-polyfill` | - | Disable the cross-browser polyfill. | polyfill enabled |
| `--starting-url ` | - | Starting URL in launched browser. | unset |
| `--no-open` | - | Launch the browser, but do not open a tab for your extension. The browser still starts and loads it. See [stop the browser from launching](#stop-the-browser-from-launching). | a tab opens on launch |
| `--no-browser` | - | Stop the browser from launching. The build still runs. See [stop the browser from launching](#stop-the-browser-from-launching). | browser launch enabled |
| `--wait [boolean]` | - | Wait for `dist/extension-js//ready.json` and exit. | disabled |
| `--wait-timeout ` | - | Timeout for `--wait` mode. | `60000` |
| `--output ` | - | Result format. `json` prints a schema-1 envelope on stdout. | `pretty` |
| `--port ` | - | Runner/devtools port when runner is enabled. Use `0` for OS-assigned port. | `8080` |
| `--host ` | - | Host to bind the dev server to. Use `0.0.0.0` for Docker/dev containers. | `127.0.0.1` |
| `--public-host ` | - | Connectable host the browser dials when it differs from the bind `--host` (remote/dev container). | bind host (`127.0.0.1` when bound to `0.0.0.0`) |
| `--extensions ` | - | Comma-separated companion extensions or store URLs. | unset |
| `--install [boolean]` | - | Internal flag. Install project dependencies when missing. | command behavior default |
| `--debug` | - | Enable maintainer diagnostics. | disabled |
Two deprecated aliases are hidden from `--help` but still work:
* `--wait-format ` maps onto `--output` and warns once on stderr. Migrate scripts to `--output`.
* `--author` and `--author-mode` map onto `--debug`.
## Browser support
`start` has no Safari path. Passing `--browser safari` (or `webkit-based`) exits with `E_COMMAND_UNSUPPORTED_FOR_TARGET`. Use [`dev`](/docs/commands/dev) or [`build`](/docs/commands/build) for Safari targets.
## Automation metadata
`start` writes readiness metadata to:
* `dist/extension-js//ready.json`
This is useful for automation when using `--no-browser`:
* Wait for `status: "ready"` before launching external runners.
* Handle `status: "error"` as a deterministic failure signal.
* Use `runId` and `startedAt` to correlate a specific runtime session.
### Stop the browser from launching
Two flags sound alike and do different things:
| You want | Flag | What happens |
| ----------------------------------------------- | -------------- | --------------------------------------------------------------------- |
| No browser at all | `--no-browser` | No browser process starts. The production build still runs. |
| A browser, but no tab opened for your extension | `--no-open` | The browser starts and loads your extension. No tab is opened for it. |
```bash theme={null}
# Stop the browser launch. The production build still runs.
extension start --no-browser
# Launch the browser without opening a tab for the extension.
extension start --no-open
```
`--no-browser` also has a config form, `commands.start.noBrowser: true`, and an environment form, `EXTENSION_CLI_NO_BROWSER=1`. `dev`, `start`, and `preview` all accept both flags.
### `--no-browser` and readiness synchronization
`--no-browser` only disables browser launch. It does not block external runners until the production build finishes.
For production-oriented Playwright, continuous integration (CI), and AI workflows:
1. Run `extension start --no-browser` as the producer process.
2. Run `extension start --wait --browser=` as the readiness gate.
3. Launch external browser automation only after `status: "ready"`.
`--wait` exits non-zero on `error`/timeout and ignores stale contracts from dead processes (`pid` no longer alive).
Because `start` can finish quickly, a contract from a completed run still counts when its timestamp is within a 60 second window.
`--wait` requires a local project path. Passing a remote URL exits with `E_ARGS`.
If you pass both `--wait` and `--no-browser` in the same invocation, `--wait` takes precedence. The command runs in wait-only mode.
### Machine output with `--output json`
`--output json` prints schema-1 envelopes on stdout, one JSON object per line:
* A plain `start` run prints one `status: "started"` frame before the build. It carries the project path, browser list, requested port, and `pid`.
* A `start --wait` run prints one `status: "ready"` frame on success. Its `value.results` array carries the full ready contract per browser.
* A failed build prints one `ok: false` frame with `status: "build-failed"` and `error.code: "E_COMPILE"` before the process exits `1`.
## Logging flags
These flags are experimental and may change between minor releases.
| Flag | What it does | Default |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------ |
| `--logs ` | Minimum log level. | `off` |
| `--log-context ` | Context filter (`background`, `content`, `page`, `sidebar`, `popup`, `options`, `devtools`). | `all` |
| `--log-format ` | Logger output format. | `pretty` |
| `--no-log-timestamps` | Disable timestamps in pretty mode. | timestamps enabled |
| `--no-log-color` | Disable color in pretty mode. | color enabled |
| `--log-url ` | Filter log events by URL substring/regex. | unset |
| `--log-tab ` | Filter log events by tab ID. | unset |
## Shared global options
Also supports [Global flags](/docs/workflows/global-flags).
## Examples
### Start with default browser
```bash npm theme={null}
extension start
```
```bash pnpm theme={null}
extension start
```
```bash yarn theme={null}
extension start
```
```bash bun theme={null}
extension start
```
```bash deno theme={null}
extension start
```
### Start in Firefox
```bash npm theme={null}
extension start --browser firefox
```
```bash pnpm theme={null}
extension start --browser firefox
```
```bash yarn theme={null}
extension start --browser firefox
```
```bash bun theme={null}
extension start --browser firefox
```
```bash deno theme={null}
extension start --browser firefox
```
### Build and skip browser launch
```bash npm theme={null}
extension start --no-browser
```
```bash pnpm theme={null}
extension start --no-browser
```
```bash yarn theme={null}
extension start --no-browser
```
```bash bun theme={null}
extension start --no-browser
```
```bash deno theme={null}
extension start --no-browser
```
## Behavior notes
* `start` does not run a dev server and does not provide hot module replacement (HMR) or watch mode.
* `start` is production-mode oriented. Use `dev` for iterative local development.
* For machine consumers, parse `dist/extension-js//ready.json` instead of terminal text.
## Next steps
* Iterate quickly with [`dev`](/docs/commands/dev).
* Launch existing build output with [`preview`](/docs/commands/preview).
# Storage command for reading and writing extension storage
Source: https://extension.js.org/docs/commands/storage
Read and write chrome.storage areas of a running Extension.js dev session from the terminal, with JSON values and per-area targeting.
Read and write the extension's `chrome.storage` areas from the terminal.
`storage` talks to a running dev session and runs the storage call inside the extension itself, so you see exactly what your code sees. No DevTools, no temporary `console.log`.
The session must run with the control channel unlocked: start it with `extension dev --allow-control`. A refusal names the missing flag.
## When to use `storage`
* You want to check what your extension persisted without wiring up a debug UI.
* A test or agent needs to seed storage state before exercising a flow.
* You want to flip a stored feature flag in a live session and watch the effect.
## Usage
```bash npm theme={null}
extension storage [project-path] [options]
```
```bash pnpm theme={null}
extension storage [project-path] [options]
```
```bash yarn theme={null}
extension storage [project-path] [options]
```
```bash bun theme={null}
extension storage [project-path] [options]
```
```bash deno theme={null}
extension storage [project-path] [options]
```
Read the whole `local` area, then one key, then write a value:
```bash theme={null}
extension storage get
extension storage get --key settings
extension storage set --key settings --value '{"theme": "dark"}'
```
## Arguments and flags
| Flag | What it does | Default |
| ------------------------- | ------------------------------------------------------------------------------------ | ------------------ |
| `` | `get` or `set`. | required |
| `[project-path]` | Path to the extension project root. | `process.cwd()` |
| `--area ` | Storage area (`local`, `sync`, `session`, `managed`). | `local` |
| `--key ` | Key to get or set. `get` without a key returns the whole area. | unset |
| `--value ` | Value to write with `set`. Parsed as JSON first, kept as a raw string if that fails. | required for `set` |
| `--context ` | Context that runs the call (`background`, `popup`, `options`, `sidebar`, `content`). | `background` |
| `--browser ` | Which session to target (`chrome`, `chromium`, `edge`, `firefox`). | `chromium` |
| `--timeout ` | Command timeout in milliseconds. | `5000` |
| `--output ` | Output format (`json` wraps the result in the schema-1 envelope). | `pretty` |
## How values are parsed
`--value` is parsed as JSON, so `'{"theme": "dark"}'`, `'42'`, and `'true'` arrive typed. Input that is not valid JSON falls back to a raw string, so `--value hello` stores the string `"hello"` without extra quoting.
`set` requires both `--key` and `--value`. Leaving either out fails with `E_ARGS` before any connection is made. Any action other than `get` or `set` fails the same way.
## Failure modes
* No session for the browser: `E_SESSION_NOT_FOUND`, with the exact `extension dev --allow-control` command to run.
* Session running without `--allow-control`: the connection is refused and the error names the flag.
* The storage call threw inside the extension (for example, writing to the read-only `managed` area): `E_STORAGE`.
* The call outlived `--timeout`: `E_TIMEOUT`.
The exit code is `0` on success and `1` on any failure. Machine consumers should read the envelope from `--output json` (see [Result envelope](/docs/contracts/result-envelope)).
## Next steps
* Run arbitrary expressions in the same session with [`eval`](/docs/commands/eval).
* Restart the background worker after seeding state with [`reload`](/docs/commands/reload).
* Diagnose a session that refuses the call with [`doctor`](/docs/commands/doctor).
* Read the wider debugging workflow in [Debugging](/docs/debugging).
# Uninstall command to remove managed browsers
Source: https://extension.js.org/docs/commands/uninstall
Remove managed browser runtimes from the Extension.js cache. Only affects browsers installed by Extension.js, not your system browsers.
Use `uninstall` to remove managed browser runtimes from the Extension.js cache.
This only removes browsers that Extension.js installed in its managed cache root. It does not touch system Chrome, system Edge, or any browser installed outside that cache.
## When to use `uninstall`
* You want to reclaim disk space from managed browsers you no longer need.
* You are resetting a managed browser install to force a fresh download on next `install`.
* You are cleaning up continuous integration (CI) caches or switching browser targets.
## Canonical usage
For a single browser, use the positional form:
```bash npm theme={null}
extension uninstall
```
```bash pnpm theme={null}
extension uninstall
```
```bash yarn theme={null}
extension uninstall
```
```bash bun theme={null}
extension uninstall
```
```bash deno theme={null}
extension uninstall
```
Use `--all` to remove every managed browser target.
## Usage
```bash npm theme={null}
extension uninstall [browser-name] [options]
```
```bash pnpm theme={null}
extension uninstall [browser-name] [options]
```
```bash yarn theme={null}
extension uninstall [browser-name] [options]
```
```bash bun theme={null}
extension uninstall [browser-name] [options]
```
```bash deno theme={null}
extension uninstall [browser-name] [options]
```
## Arguments and flags
| Flag / argument | What it does | Default |
| ------------------------- | ---------------------------------------------------------------------------------- | -------- |
| `[browser-name]` | Remove a single managed browser such as `chrome`, `chromium`, `edge`, or `firefox` | unset |
| `--browser ` | Explicit flag form of the browser argument | unset |
| `--all` | Remove all managed browser runtimes from the Extension.js cache | disabled |
| `--where` | Print the resolved cache root, or browser-specific managed paths | disabled |
| `--output ` | Result format. `json` prints a schema-1 envelope on stdout | `pretty` |
`--all` removes the same managed set that `install --browser all` creates: `chrome`, `chromium`, `edge`, and `firefox`.
## Machine output with `--output json`
`--output json` prints one schema-1 envelope on stdout:
* A successful removal prints a `status: "uninstalled"` frame naming the target in `value`.
* `--where` prints a `status: "located"` frame with the resolved paths in `value.paths`.
* A failed removal prints `ok: false` with `error.code: "E_BROWSER_UNINSTALL"` before the process exits `1`.
## Examples
### Remove managed Chrome for Testing
```bash theme={null}
extension uninstall chrome
```
### Remove all managed browsers
```bash theme={null}
extension uninstall --all
```
### Show the managed uninstall path for Firefox
```bash theme={null}
extension uninstall firefox --where
```
## Best practices
* **Use `--all` in CI teardown** to clean up managed browsers after test runs.
* **Use `--where` first** to confirm what will be removed before scripting bulk uninstalls.
* `uninstall` is safe for system browsers. It only removes Extension.js-managed cache folders.
## Behavior notes
* If you set `EXT_BROWSERS_CACHE_DIR`, uninstall uses that custom cache root.
## Next steps
* Reinstall managed browsers with [`install`](/docs/commands/install).
* Review browser targeting with [`dev`](/docs/commands/dev) and [`start`](/docs/commands/start).
* Learn about [Running other browsers](/docs/browsers/running-other-browsers) with custom binary paths.
# Extension.js vs WXT
Source: https://extension.js.org/docs/compare/extension-js-vs-wxt
Side-by-side comparison of Extension.js and WXT for building cross-browser extensions. CLI ergonomics, manifest model, framework support, and reload behavior.
Extension.js and [WXT](https://wxt.dev) are both modern frameworks for building cross-browser extensions. They overlap in mission and differ in philosophy. This page is a factual comparison so you can choose based on your project, not marketing copy.
## TL;DR: which should you pick?
* You want the **`manifest.json` as the source of truth**, not generated from config.
* You like staying close to the native WebExtension model (real files, transparent output).
* You want to dev a remote sample with one command: `extension dev `.
* You want Rspack's Rust-speed builds and first-class [AI/MCP tooling](/docs/ai-access).
* You prefer **file-system convention** (`entrypoints/`) over an explicit manifest.
* You need **Manifest V2** as a primary target.
* You want `browser` auto-imported in every file, resolved to the native
`browser` object or to `chrome`.
The rest of this page backs up that summary, dimension by dimension.
## At a glance
| Dimension | Extension.js | WXT |
| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Bundler | [Rspack](/docs/features/rspack-configuration) (Rust-based) | Vite (Rolldown migration in progress) |
| Manifest | One `manifest.json`, browser-prefixed keys filtered at compile time | `wxt.config.ts` generates `manifest.json` per build |
| Entrypoints | Files referenced from `manifest.json` | File-system convention under `entrypoints/` |
| Browser targets | Chrome, Edge, Firefox, Safari (macOS, through Xcode), Chromium, Gecko, custom binaries | Chrome, Edge, Firefox, Safari (build only, no browser launch), custom binaries |
| Manifest V3 | Default | Default |
| Manifest V2 | Not supported as a primary target | Supported via `manifestVersion: 2` |
| Reload model | HMR for popup/options/devtools, targeted reload for content scripts and SW | HMR for popup/options/devtools, targeted reload for content scripts |
| `browser.*` polyfill | `--polyfill`, on by default in `dev` and off in `build` | `browser` aliased to `chrome`, real polyfill opt-in through `@wxt-dev/webextension-polyfill` |
| Templates | React, Preact, Vue, Svelte, TypeScript, JavaScript, init | React, Vue, Svelte, Solid, vanilla |
| TypeScript | First-class | First-class |
| AI surfaces | Hosted MCP server + `llms.txt` ([details](/docs/ai-access)) | `llms.txt` available |
## Mental model
**Extension.js stays close to the platform.** You author a `manifest.json` and reference real files. The CLI compiles, filters per browser, and ships. If you already understand how a browser extension is structured, the framework gets out of your way.
**WXT abstracts the platform.** Entrypoints are inferred from a directory layout (`entrypoints/popup/`, `entrypoints/content.ts`), and the manifest is generated from your config and source. If you prefer convention over configuration, WXT does more for you per file you write.
Neither approach is universally better. The choice depends on whether you want to **see** your manifest or **declare** your manifest.
## CLI surface
### Extension.js
```bash theme={null}
extension dev --browser=chrome,firefox
extension build --browser=chrome,firefox --zip
extension dev https://github.com/user/repo/tree/main/path
```
The argument can also be a remote GitHub URL or a ZIP archive, useful for spinning up a sample without cloning. See [Get started immediately](/docs/getting-started/immediately).
### WXT
```bash theme={null}
wxt
wxt build
wxt build -b firefox
wxt zip
```
WXT splits dev/build/zip into separate commands. Extension.js consolidates packaging into `build --zip`.
## Cross-browser strategy
Both frameworks ship one codebase to multiple browsers, with different mechanics:
* **Extension.js** uses [browser-prefixed manifest fields](/docs/features/browser-specific-fields) (`chrome:`, `firefox:`, `gecko:`, etc.) inside one `manifest.json`. The unprefixed keys apply everywhere, and prefixed keys land only in matching builds.
* **WXT** computes the manifest from `wxt.config.ts` and per-target overrides. Browser differences live in TypeScript config rather than in the manifest itself.
The two projects also reach the `browser` namespace differently. Extension.js bundles [webextension-polyfill](https://github.com/mozilla/webextension-polyfill) when you pass `--polyfill`, which `extension dev` does by default and `extension build` does not. WXT auto-imports a `browser` object from `wxt/browser`. As of wxt 0.21.4 that object is a native alias, `globalThis.browser` when the browser provides one and `globalThis.chrome` otherwise, so it is not a polyfill. The full polyfill is a separate opt-in module, `@wxt-dev/webextension-polyfill`.
If your team includes designers or PMs who read `manifest.json`, prefixed keys keep that file as the source of truth. If your team wants extension config alongside other TypeScript config, WXT's approach reads more naturally.
## Migration paths
If you are already on WXT and looking to evaluate Extension.js, the typical migration touches three things:
1. Move `entrypoints/` content back to flat files referenced from a hand-authored `manifest.json`.
2. Replace `wxt.config.ts` with [`extension.config.js`](/docs/features/extension-configuration) for build defaults.
3. Replace `wxt`/`wxt build` scripts with `extension dev`/`extension build`.
Most React, Preact, Vue, and Svelte source files copy over without changes. The full step-by-step
guide is at [Migrate from WXT](/docs/migrate/from-wxt).
## When to choose Extension.js
* You want the manifest as the source of truth, not generated.
* You want one CLI argument to dev a remote sample (`extension dev `).
* You want first-class hosted MCP for AI tooling on the docs.
* You want Rspack's Rust-speed compile times for large extensions.
## When to choose WXT
* You prefer file-system convention over an explicit manifest.
* You need Manifest V2 as a primary target.
* You want `browser` auto-imported everywhere, with the full polyfill available as an opt-in module.
## See also
* [Cross-browser compatibility](/docs/features/cross-browser-compatibility)
* [Browser-specific manifest fields](/docs/features/browser-specific-fields)
* [Templates](/docs/getting-started/templates)
# Browser extension framework comparison
Source: https://extension.js.org/docs/compare/index
Compare browser extension frameworks: Extension.js, WXT, CRXJS, and Plasmo. Manifest model, bundler, cross-browser output, and migration guides for each.
A browser extension framework handles the work that browsers do not. It compiles TypeScript and modern JavaScript for every extension surface, wires the manifest to real output files, reloads service workers and content scripts during development, and emits per-browser builds. This page compares the active options so you can choose with full information.
## The frameworks
| Dimension | Extension.js | WXT | CRXJS | Plasmo |
| ---------------- | --------------------------------------------- | ---------------------------- | ------------------------------ | ----------------------------- |
| Model | `manifest.json` as source of truth | File conventions + config | Vite plugin reading a manifest | File conventions |
| Bundler | Rspack (internal, zero config) | Vite | Vite (plugin layer) | Parcel |
| Cross-browser | Chrome, Edge, Firefox, Safari from one source | Chrome, Firefox, MV2 support | Chromium focus | Chrome, Firefox targets |
| Setup | None required | `wxt.config.ts` | `vite.config.ts` + plugin | `package.json` manifest field |
| Bundler coupling | Internal detail | Tracks Vite majors | Breaks with bundler changes | Tied to Parcel |
Two structural differences matter more than any feature row:
* **Where the manifest lives.** Extension.js reads your `manifest.json` and compiles whatever it declares. WXT and Plasmo generate the manifest from file conventions and config. CRXJS reads a manifest but resolves it through Vite's plugin API, which is why bundler upgrades can break it (see [the Vite 8 fileName error](/docs/migrate/crxjs-content-script-filename-undefined)).
* **Who owns the bundler.** A framework that wraps a general-purpose bundler inherits that bundler's breaking changes. Extension.js treats the bundler as an internal detail: you never configure it, and upgrades are the framework's problem, not yours.
## Detailed comparisons and migrations
* [Extension.js vs WXT](/docs/compare/extension-js-vs-wxt): the closest comparison, dimension by dimension.
* [Migrate from CRXJS](/docs/migrate/from-crxjs): step-by-step, about ten minutes for a typical project.
* [Fix: Content script fileName is undefined](/docs/migrate/crxjs-content-script-filename-undefined): the CRXJS + Vite 8 build error, with workarounds.
* [Migrate from Plasmo](/docs/migrate/from-plasmo): convention-to-manifest mapping, env vars, and CSUI.
## Try it without committing
Run any extension template, or any extension repository on GitHub, with one command:
```bash theme={null}
npx extension@latest dev
```
Your components, `chrome.*` calls, and styling carry over from any of these frameworks. The [migration guides](/docs/migrate/from-crxjs) cover the wiring that changes.
# JavaScript and TypeScript files in browser extensions
Source: https://extension.js.org/docs/concepts/javascript-typescript-browser-extension-files
Learn how .js, .ts, .tsx, manifest.json, background scripts, and content scripts work in Chrome, Firefox, and Edge extensions built with Extension.js.
Browser extensions use JavaScript, TypeScript, HTML, CSS, and a `manifest.json` to define behavior, UI, permissions, and browser integration. This page covers the file extensions you will see in a browser extension project and which ones land where.
If you are looking for "extension" as in a software add-on for Chrome or Firefox, start at [What is a browser extension?](/docs/concepts/what-is-a-browser-extension) instead.
## JavaScript file extensions: `.js` and `.mjs`
Browser extensions accept the same JavaScript file extensions the rest of the web platform uses:
| File extension | What it means |
| -------------- | ----------------------------------------------------------------------------------------- |
| `.js` | Plain JavaScript. Treated as ES module or classic script depending on how it is loaded. |
| `.mjs` | ES module. Useful when you want to force module semantics, especially in service workers. |
| `.cjs` | CommonJS. Rare in extension source code; sometimes appears in `extension.config.cjs`. |
In Manifest V3, the background `service_worker` runs as a module when `manifest.json` includes `"type": "module"` in the `background` block. See [Manifest V3 troubleshooting](/docs/concepts/manifest-v3) for the details.
## TypeScript file extensions: `.ts` and `.tsx`
TypeScript works as a first-class source language in Extension.js:
| File extension | What it means |
| -------------- | ------------------------------------------------------- |
| `.ts` | TypeScript without JSX. |
| `.tsx` | TypeScript with JSX (React, Preact). |
| `.d.ts` | Type declaration file. Not emitted to the build output. |
You do not need to write a `tsconfig.json` from scratch. Extension.js ships sensible defaults. Types for `chrome.*`, `browser.*`, `import.meta.env`, and the public env keys come from `@types/chrome` and Extension.js's own ambient types. See [TypeScript](/docs/languages-and-frameworks/typescript).
## React file extensions: `.jsx` and `.tsx`
React in a browser extension uses the standard JSX file extensions:
| File extension | What it means |
| -------------- | -------------------- |
| `.jsx` | JavaScript with JSX. |
| `.tsx` | TypeScript with JSX. |
React works inside extension pages (popup, options, side panel, new-tab) and inside content scripts injected into web pages. See [React](/docs/languages-and-frameworks/react) for setup and shadow-DOM patterns.
## Browser extension files: `manifest.json`, background, content scripts, pages
Beyond JavaScript and TypeScript source, a browser extension folder usually contains:
| File or folder | Purpose |
| ---------------- | ----------------------------------------------------------------- |
| `manifest.json` | Declares name, version, permissions, entry points, and metadata. |
| Background entry | Long-lived event handler. Manifest V3 service worker on Chromium. |
| Content scripts | Code injected into web pages matching a URL pattern. |
| Extension pages | Popup, options, side panel, or new-tab HTML. |
| Locales | `_locales//messages.json` for translated strings. |
| Icons and assets | Toolbar icons, web-accessible resources, fonts, images. |
Extension.js compiles your `.ts`, `.tsx`, `.jsx`, `.vue`, `.svelte`, `.css`, `.less`, `.scss`, and `.module.*` source down to that on-disk layout. It produces a separate folder per browser target (`dist/chrome`, `dist/firefox`, `dist/edge`).
## How Extension.js compiles JavaScript and TypeScript extensions
When you run `extension dev` or `extension build`, Extension.js:
1. Reads `manifest.json` and finds every entry point (background, content scripts, popup, options, side panel, new-tab, web-accessible HTML).
2. Resolves source files referenced from those entries, including imports across `.ts`, `.tsx`, `.jsx`, `.vue`, `.svelte`, and stylesheet types.
3. Compiles through Rspack with extension-aware defaults: code splitting where it helps, no chunking where the browser refuses it (service workers, content scripts).
4. Emits the result into `dist/` with a manifest filtered for that target.
You write source in any of the file extensions above. Extension.js handles the bundling, polyfills, reload-on-save loop, and per-browser packaging.
## Next steps
* Try [`extension create`](/docs/commands/create) to scaffold a TypeScript or React extension.
* Read [TypeScript](/docs/languages-and-frameworks/typescript) and [React](/docs/languages-and-frameworks/react).
* Read [Manifest V3 troubleshooting](/docs/concepts/manifest-v3) for service worker and module rules.
* Build with [`extension build`](/docs/commands/build).
# Manifest V3 service workers, content scripts, and host_permissions explained
Source: https://extension.js.org/docs/concepts/manifest-v3
How Manifest V3 changes service workers, content scripts, web_accessible_resources, and host_permissions in Chrome and Firefox, with the exact manifest.json fix for each common MV3 error.
Manifest V3 (MV3) replaced background pages with service workers, tightened content security policy, and reshaped how extensions declare network and host access. Most day-to-day pain points share one root cause: MV3 assumes ephemeral, event-driven background code. Chrome and Firefox also interpret a few keys differently.
This page collects the issues that come up most often when building MV3 extensions, with the exact fix and what Extension.js handles for you.
## Manifest V3 background `service_worker` with `type: "module"`
Chromium uses a service worker for the MV3 background. To import ES modules from it, the manifest needs `type: "module"`:
```json theme={null}
{
"manifest_version": 3,
"background": {
"service_worker": "service_worker.js",
"type": "module"
}
}
```
Without `"type": "module"`, `import` statements fail at registration with no clear error in the extension console. With it, you can write modern ES module syntax in the worker file.
Extension.js bundles your background entry, including `.ts`/`.tsx` workers, into a single output file, so `import` statements resolve at build time and the worker registers without `type: "module"`. Declare `type: "module"` yourself when you want a real module worker, and Extension.js keeps it in the emitted manifest.
## Why `background.js` behaves differently in Manifest V3
In Manifest V2, `background.js` ran in a persistent background page with a DOM. In Manifest V3 on Chromium, the background runs as a service worker:
* No DOM. `window`, `document`, `XMLHttpRequest`, and `localStorage` are gone. Use `fetch` and `chrome.storage`.
* The worker can be **terminated at any time** when idle and woken on the next event. Do not store state in module-scope variables; persist it in `chrome.storage`.
* Top-level `await` is allowed, but long initialization will not keep the worker alive on its own.
* Register event listeners **synchronously** at the top of the file, not inside async callbacks. Listeners registered inside async callbacks will not fire on the wake-up event that loaded the worker.
Firefox keeps non-persistent event pages instead of service workers. Extension.js routes Chromium to `service_worker` and Firefox to `scripts` from the same source, so the same background entry compiles correctly per target.
## `web_accessible_resources` in Manifest V3
Manifest V3 changed `web_accessible_resources` from a flat array of files to a list of `{ resources, matches }` blocks:
```json theme={null}
{
"web_accessible_resources": [
{
"resources": ["images/logo.png", "pages/injected.html"],
"matches": ["https://example.com/*"]
}
]
}
```
Common mistakes:
* Listing a path that is not in the build output. Extension.js only emits files that are referenced from `manifest.json`, the entry HTML, or imported code. Add the file as an asset so it lands in `dist/`.
* Forgetting `matches`. Without it, the resource is not exposed to any origin.
* Using V2-style flat strings. The browser silently ignores them in MV3.
See [`web_accessible_resources` implementation](/docs/implementation-guide/web-accessible-resources) for the full pattern, including injecting an extension URL into a content script.
## `host_permissions` vs `permissions`
Manifest V3 split host access out of the `permissions` array:
| Key | What it controls |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
| `permissions` | Named API surfaces (`storage`, `tabs`, `cookies`, `scripting`, `alarms`, etc.). |
| `host_permissions` | URL match patterns the extension can read or modify (`https://*/*`, `*://api.example.com/*`). |
| `optional_permissions` and `optional_host_permissions` | Permissions requested at runtime via `chrome.permissions.request`. |
Symptoms of mixing them up:
* `chrome.cookies.get` returning empty for a site you have access to: the URL needs `host_permissions`, not just `cookies`.
* `chrome.scripting.executeScript` failing with "Cannot access contents of url": add the URL to `host_permissions`.
* Web store warning users about "all-site" access when you only need one origin: narrow the host pattern.
See [Permissions and host permissions](/docs/implementation-guide/permissions-and-host-permissions) for the per-API reference.
## `declarative_net_request` in Firefox
`declarative_net_request` (DNR) is the MV3 replacement for blocking `webRequest`. Firefox supports it, but with a few constraints:
* Static rule resource files (`rule_resources`) must be valid JSON arrays. Firefox is stricter about empty or malformed rule files than Chrome.
* Some rule actions and conditions Chrome supports are still partial in Firefox. Check [MDN's `declarativeNetRequest` reference](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/declarativeNetRequest) for the current matrix.
* `host_permissions` (or ``) is required for DNR rules to apply on cross-origin requests.
Extension.js validates the DNR resources at build time and emits per-browser artifacts so a Firefox-specific rule file does not end up in the Chrome build.
## Content scripts, workers, and extension URLs
Three places where MV3 trips people up:
* **Content scripts cannot access the page's JavaScript scope.** They share the DOM, not the window. Use `window.postMessage` (or a `
```
## Best practices
* Keep module styles close to components (`Component.module.css`) for easier ownership.
* Use `:global(...)` sparingly and only for intentional global overrides.
* Prefer module styles for extension pages and framework components where class mapping is explicit.
## Next steps
* Learn more about [Sass and Sass modules](/docs/languages-and-frameworks/sass).
* Learn more about [Less and Less modules](/docs/languages-and-frameworks/less).
## Video walkthrough
# Deno projects with Extension.js
Source: https://extension.js.org/docs/languages-and-frameworks/deno
Scaffold and build browser extensions in Deno-first projects. Extension.js reads deno.jsonc as the project manifest and resolves npm: imports.
Extension.js treats Deno as a first-class runtime. Scaffold with Deno and the project gets a `deno.jsonc` instead of a `package.json`. Work in a hybrid project and `deno.jsonc` sits beside `package.json` for Deno-native settings. Either way, `extension dev` and `extension build` behave the same.
## Create a project with Deno
```bash theme={null}
deno run -A npm:extension@latest create my-extension --template=react
```
Then run the dev task:
```bash theme={null}
cd my-extension
deno task dev
```
## Primary mode: deno.jsonc is the project manifest
When you scaffold under the Deno runtime, `deno.jsonc` becomes the project's only manifest and `package.json` is removed.
* Template dependencies move into `imports` as `npm:` specifiers, and `deno install` resolves them.
* The `extension` engine itself is declared there too, as `npm:extension@`.
* `nodeModulesDir` is set to `"auto"`, so `npm:` dependencies materialize in a real `node_modules` directory. The bundler resolves project dependencies from it at dev and build time.
* `deno task ` also finds binaries in `node_modules/.bin`, so the generated tasks run the locally installed Extension.js CLI.
A trimmed example:
```jsonc theme={null}
{
"imports": {
"react": "npm:react@^19.0.0",
"react-dom": "npm:react-dom@^19.0.0",
"extension": "npm:extension@^4.0.0"
},
"nodeModulesDir": "auto",
"tasks": {
"dev": "extension dev",
"build": "extension build"
}
}
```
When a template ships its own Deno config, Extension.js merges into that file instead of creating a second one. Deno's own discovery prefers `deno.json` over `deno.jsonc`, so the merge targets the file that Deno will read.
## Companion mode: deno.jsonc beside package.json
Monorepo templates, and projects that already have a `package.json`, keep it. Dependencies stay declared in `package.json`, and `deno install` resolves them from there. The companion `deno.jsonc` carries only Deno-native settings and the `tasks` that run the CLI.
## How the toolchain detects Deno
Detection works on files, not on how you invoked the CLI:
* Extension.js reads `deno.jsonc` or `deno.json` when it scans project dependencies. Framework, CSS, and TypeScript detection all see packages that `imports` declares through `npm:` specifiers.
* When both manifests declare the same package, the `package.json` entry wins.
* A project counts as Deno-managed when it has a `deno.lock`, or a Deno config with no `package.json` beside it. An npm-family lockfile wins for hybrids, and Deno is claimed only when the `deno` binary is on your PATH.
## TypeScript in Deno projects
TypeScript detection follows the same rules as everywhere else. See [TypeScript](/docs/languages-and-frameworks/typescript): SWC compiles the sources, and the `typescript` package is only needed for `tsc --noEmit`.
## Next steps
* Learn how Extension.js handles [Node APIs](/docs/languages-and-frameworks/node).
* Learn how to manage [Extension configuration](/docs/features/extension-configuration).
# ECMAScript modules in extensions
Source: https://extension.js.org/docs/languages-and-frameworks/ecmascript-modules
Use modern import/export syntax across background scripts, content scripts, and extension pages. Extension.js supports ESM through its Rspack pipeline.
Author extension code with modern `import`/`export` syntax across all extension contexts.
Extension.js supports ECMAScript Modules (ESM) in background scripts, content scripts, and extension pages. It uses the default Rspack pipeline for module resolution.
## When ESM is a good fit
* You want consistent module syntax across extension and web code.
* You are sharing code with modern ESM-first packages.
* You need cleaner tree-shaking (automatic removal of unused code) and explicit dependency boundaries.
## Template examples
### `new`
Start a new-tab extension with modern module syntax and minimal setup.
```bash npm theme={null}
npx extension@latest create my-extension --template=newtab
```
```bash pnpm theme={null}
pnpx extension@latest create my-extension --template=newtab
```
```bash yarn theme={null}
yarn dlx extension@latest create my-extension --template=newtab
```
```bash bun theme={null}
bunx extension@latest create my-extension --template=newtab
```
```bash deno theme={null}
deno run -A npm:extension@latest create my-extension --template=newtab
```
Repository: [extension-js/examples/newtab](https://github.com/extension-js/examples/tree/main/examples/newtab)
### `content`
Inject script logic into page content while keeping ESM-style authoring.
```bash npm theme={null}
npx extension@latest create my-extension --template=content
```
```bash pnpm theme={null}
pnpx extension@latest create my-extension --template=content
```
```bash yarn theme={null}
yarn dlx extension@latest create my-extension --template=content
```
```bash bun theme={null}
bunx extension@latest create my-extension --template=content
```
```bash deno theme={null}
deno run -A npm:extension@latest create my-extension --template=content
```
Repository: [extension-js/examples/content](https://github.com/extension-js/examples/tree/main/examples/content)
## Usage with an existing extension
You can use ESM syntax directly in extension source files (`.js`, `.mjs`, `.ts`, `.tsx`, etc.) without custom bundler setup.
If your Node.js project configuration files should also run as ESM, set `"type": "module"` in `package.json`. This applies to custom scripts and configuration conventions:
```json theme={null}
{
"name": "my-extension",
"version": "1.0",
"description": "My Extension Example",
"type": "module",
"devDependencies": {
"extension": "latest"
},
"scripts": {
"dev": "extension dev",
"start": "extension start",
"build": "extension build"
}
}
```
## Manifest and service worker notes
For Manifest V3 background workers:
* Set `background.type` to `"module"` when you want native ES module loading in the service worker.
* Without module worker type, Extension.js uses classic worker loading (bundled scripts without native module syntax).
```json theme={null}
{
"background": {
"service_worker": "src/background.ts",
"type": "module"
}
}
```
## ESM vs CommonJS reminders
When writing ESM modules:
### Include file extensions in relative imports
```diff theme={null}
// my-file.mjs
import React from 'react'
// local imports
- import myImport from './myImport'
+ import myImport from './myImport.js'
```
### Interoperability with non-ESM modules can differ
When importing CommonJS packages from ESM, follow the compatibility guidance each package provides.
### CommonJS globals are not available in strict ESM contexts
Avoid relying on `require`, `module.exports`, `__filename`, and `__dirname` inside ESM modules.
## Handling environment variables in ECMAScript modules
Extension.js supports both:
* `process.env.EXTENSION_PUBLIC_*`
* `import.meta.env.EXTENSION_PUBLIC_*`
Use `EXTENSION_PUBLIC_` for variables you want available in extension code.
```js theme={null}
// example.mjs
console.log(import.meta.env.EXTENSION_PUBLIC_API_KEY);
console.log(process.env.EXTENSION_PUBLIC_API_KEY);
```
## Do imports need mandatory file extensions?
Node.js requires explicit file extensions in ESM import specifiers (`import "./util.js"`, never `import "./util"`). That rule applies to code Node runs directly, and it confuses people coming to extensions from server code.
In Extension.js the bundler resolves imports, not Node, so both styles work in extension code:
```js theme={null}
import { parse } from "./util"; // resolved by the bundler
import { parse } from "./util.js"; // also fine
```
Two places the Node rule still matters:
* Scripts your `package.json` runs with Node directly (build helpers, codegen): extensions are mandatory there.
* `.mjs` files shared between Node tooling and extension code: write extensions so both resolvers accept them.
## Next steps
* Learn more about [TypeScript](/docs/languages-and-frameworks/typescript).
* Explore how Extension.js handles [CSS Modules](/docs/languages-and-frameworks/css-modules).
# Types for the chrome and browser APIs
Source: https://extension.js.org/docs/languages-and-frameworks/extension-api-types
Install @types/chrome so tsc resolves chrome.* in a TypeScript extension. Extension.js ships extension-env.d.ts and references the types, but does not install them.
Extension.js compiles TypeScript with SWC, which strips types and never checks them. Type checking is a separate step that you run with `tsc`. This page covers the packages that step needs to resolve `chrome.*` and `browser.*`.
## What Extension.js generates
When a project uses TypeScript, `extension dev` and `extension build` write an `extension-env.d.ts` file beside `package.json`. It is regenerated on every run, so do not edit it.
The file pulls in the ambient types that the `extension` package publishes:
```ts extension-env.d.ts theme={null}
///
///
```
Those references give you:
| Reference | What it declares |
| -------------------------- | ------------------------------------------------------------------- |
| `extension/types` | The `browser` global, `process.env` keys, `import.meta.env` keys |
| `extension/types/polyfill` | The `browser.*` namespace shape from `webextension-polyfill` |
| Wildcard modules | `import` of `.css`, `.module.css`, `.png`, `.svg`, and other assets |
The `EXTENSION_*` environment keys are typed here too. That is why `process.env.EXTENSION_MODE` resolves without extra setup.
## Install @types/chrome for the chrome namespace
`extension/types` declares the `browser` global itself, but it reaches the `chrome` namespace through a reference:
```ts theme={null}
///
```
That reference resolves only when `@types/chrome` is installed in your project. Extension.js does not install it, and the templates do not declare it.
A scaffolded TypeScript project that calls `chrome.storage` therefore fails `tsc`:
```plaintext theme={null}
src/background.ts(19,1): error TS2304: Cannot find name 'chrome'.
src/content/scripts.ts(87,30): error TS2503: Cannot find namespace 'chrome'.
```
Install the package to clear it:
```bash npm theme={null}
npm install -D @types/chrome
```
```bash pnpm theme={null}
pnpm add -D @types/chrome
```
```bash yarn theme={null}
yarn add -D @types/chrome
```
```bash bun theme={null}
bun add -d @types/chrome
```
```bash deno theme={null}
deno add -D npm:@types/chrome
```
Run the check again and the errors are gone:
```bash theme={null}
npx tsc --noEmit
```
Nothing else changes. The build already succeeded before the install, because SWC never reads the types.
## When you write browser.\* instead
The `browser` global is typed by `extension/types`, which maps it onto `webextension-polyfill`. For the full namespace shape, add the matching types package:
```bash npm theme={null}
npm install -D @types/webextension-polyfill
```
```bash pnpm theme={null}
pnpm add -D @types/webextension-polyfill
```
```bash yarn theme={null}
yarn add -D @types/webextension-polyfill
```
```bash bun theme={null}
bun add -d @types/webextension-polyfill
```
```bash deno theme={null}
deno add -D npm:@types/webextension-polyfill
```
Read [Cross-browser compatibility](/docs/features/cross-browser-compatibility) for the runtime side of the same choice.
## Keep extension-env.d.ts in the include list
The generated file only helps when TypeScript reads it. The scaffolded `tsconfig.json` names it:
```json tsconfig.json theme={null}
{
"include": ["./", "extension-env.d.ts"],
"exclude": ["node_modules", "dist"]
}
```
When Extension.js writes a `tsconfig.json` for a project that has none, that file carries no `include` array. TypeScript then reads every file under the project folder, so it finds `extension-env.d.ts` anyway. An `include` array of your own that omits the file breaks asset imports and the `browser` global.
## Symptoms and fixes
| Symptom | Cause | Fix |
| ----------------------------------- | ---------------------------------------- | ----------------------------------------- |
| `Cannot find name 'chrome'` | `@types/chrome` is not installed | Install `@types/chrome` |
| `Cannot find namespace 'chrome'` | Same cause, in a type position | Install `@types/chrome` |
| `Cannot find module './styles.css'` | `extension-env.d.ts` is out of `include` | Add the file to `include` |
| `Cannot find name 'browser'` | The project never ran `dev` or `build` | Run either command once to generate types |
## Best practices
* Treat `extension-env.d.ts` as build output. Commit it if you like, but never edit it.
* Add `@types/chrome` to any TypeScript project that calls `chrome.*`, including one that you scaffolded from a template.
* Run `tsc --noEmit` in continuous integration. The Extension.js build does not fail on type errors.
## Next steps
* Read the rest of the [TypeScript setup](/docs/languages-and-frameworks/typescript).
* Learn about [environment variables](/docs/features/environment-variables) that the types declare.
* Review [cross-browser compatibility](/docs/features/cross-browser-compatibility).
# Languages and frameworks guide
Source: https://extension.js.org/docs/languages-and-frameworks/index
See which languages and frameworks Extension.js supports including TypeScript, React, Vue, Svelte, Preact, Sass, Less, CSS Modules, and WebAssembly.
Choose the right language and UI framework strategy for your extension surfaces, then use Extension.js defaults to keep setup minimal.
## Choose by goal
| Goal | Start here |
| ----------------------------------------- | ---------------------------------------------------------------------------------------- |
| Strong typing and safer refactors | [TypeScript](/docs/languages-and-frameworks/typescript) |
| Rich UI components with large ecosystem | [React](/docs/languages-and-frameworks/react) |
| Smaller React-like runtime | [Preact](/docs/languages-and-frameworks/preact) |
| Vue single-file component workflow | [Vue.js](/docs/languages-and-frameworks/vue) |
| Compiled components with minimal runtime | [Svelte](/docs/languages-and-frameworks/svelte) |
| Modern module syntax | [ECMAScript modules](/docs/languages-and-frameworks/ecmascript-modules) |
| Scoped styling by default | [CSS Modules](/docs/languages-and-frameworks/css-modules) |
| Sass or Less styling workflows | [Sass](/docs/languages-and-frameworks/sass), [Less](/docs/languages-and-frameworks/less) |
| Browser-safe Node compatibility decisions | [Node APIs in browser extensions](/docs/languages-and-frameworks/node) |
| Performance-heavy wasm workloads | [WebAssembly](/docs/languages-and-frameworks/webassembly) |
## Suggested progression
1. Choose language/runtime (`TypeScript`, `React`, `Preact`, `Vue`, or `Svelte`).
2. Choose styling model (`CSS Modules`, `Sass`, or `Less`).
3. Add build/runtime constraints (`Node APIs`, `WebAssembly`) only when needed.
JSX pages compile for the framework the project installs: React, Preact, Vue (through `vue/jsx-runtime`), and Solid (through a shipped adapter over `solid-js/h`, so no Babel preset is needed). Svelte components are not JSX and keep compiling through `svelte-loader`. The parser follows the file extension: `.tsx` and `.jsx` files parse JSX, and a `.ts` file never does.
## Next steps
* Add ecosystem tooling in [Integrations](/docs/integrations/index).
* Keep command usage clear in [Commands reference](/docs/commands/index).
# Less CSS in browser extensions
Source: https://extension.js.org/docs/languages-and-frameworks/less
Use Less CSS and Less modules across browser extension pages and UI components. Extension.js configures the Rspack preprocessor for .less files.
Use Less CSS across browser extension pages and UI components with a single pipeline that also supports module-scoped styles.
Extension.js detects Less usage, configures the preprocessors in the Rspack build, and supports both `.less` and `.module.less` files in browser extensions.
## When Less is a good fit
* You are migrating an existing Less-based web codebase.
* You want variables and nesting with low migration cost.
* You need module-scoped styles while keeping Less conventions.
## Template examples
### `new-less`
Start a new-tab extension with Less support already configured.
```bash npm theme={null}
npx extension@latest create my-extension --template=newtab-less
```
```bash pnpm theme={null}
pnpx extension@latest create my-extension --template=newtab-less
```
```bash yarn theme={null}
yarn dlx extension@latest create my-extension --template=newtab-less
```
```bash bun theme={null}
bunx extension@latest create my-extension --template=newtab-less
```
```bash deno theme={null}
deno run -A npm:extension@latest create my-extension --template=newtab-less
```
Repository: [extension-js/examples/newtab-less](https://github.com/extension-js/examples/tree/main/examples/newtab-less)
## Usage with an existing extension
Add Less to an existing extension with the steps below.
### Installation
Install the required dependencies:
```bash npm theme={null}
npm install -D less less-loader
```
```bash pnpm theme={null}
pnpm add -D less less-loader
```
```bash yarn theme={null}
yarn add -D less less-loader
```
```bash bun theme={null}
bun add -d less less-loader
```
```bash deno theme={null}
deno add -D npm:less npm:less-loader
```
### Example usage in an HTML file
In extension pages (popup/options/new tab), import Less through your script entry:
```html theme={null}
New Extension
Hello, Extension.
```
```ts theme={null}
import "./styles/globals.less";
console.log("Less loaded");
```
### In a `content_script` file
```ts theme={null}
import "./styles/content.less";
console.log("Content styles loaded");
```
## Less modules in browser extensions
Scope class names locally with Less modules, just like CSS Modules. This prevents global style conflicts by default, which matters in extensions because content scripts run inside a host page's CSS scope.
### `content-less-modules`
Use Less modules in content scripts when you need scoped styles on injected UI.
```bash npm theme={null}
npx extension@latest create my-extension --template=content-less-modules
```
```bash pnpm theme={null}
pnpx extension@latest create my-extension --template=content-less-modules
```
```bash yarn theme={null}
yarn dlx extension@latest create my-extension --template=content-less-modules
```
```bash bun theme={null}
bunx extension@latest create my-extension --template=content-less-modules
```
```bash deno theme={null}
deno run -A npm:extension@latest create my-extension --template=content-less-modules
```
Repository: [extension-js/examples/content-less-modules](https://github.com/extension-js/examples/tree/main/examples/content-less-modules)
## Using Less modules in an existing extension
To enable Less modules, rename your `.less` files to include `.module.less`. This enables automatic local scoping of class names.
```diff theme={null}
- myStyles.less
+ myStyles.module.less
```
### Example usage
After renaming your Less file, you can import it into your scripts:
```ts theme={null}
import styles from "./styles/myStyles.module.less";
const element = document.createElement("h1");
element.className = styles.primary;
element.innerText = "Hello, Extension!";
document.body.appendChild(element);
```
## React/Preact example usage
To use Less modules in React or Preact, import your Less module and apply styles as you would in any component:
```jsx theme={null}
import styles from "./styles/myStyles.module.less";
export default function MyComponent() {
return
Hello, Extension!
;
}
```
## Vue example usage
In Vue, prefer single-file component (SFC) styles with `lang="less"` and `module`:
```vue theme={null}
Hello, Extension!
```
## Svelte example usage
In Svelte, import Less module mappings and apply classes:
```svelte theme={null}
Hello, Extension!
```
## Behavior notes
* In extension page contexts, `.module.less` exports class maps as expected.
* In content scripts, Extension.js emits styles as CSS assets and injects them as stylesheets into the page.
* If the `less` package is not installed, Extension.js warns and copies the raw Less source into the output CSS, so those surfaces render unstyled until you install it yourself.
## About the `less` post-install warning
With some package managers, installing a project that uses Less can surface a
notice that build scripts for `less` were not run (pnpm prints
`Ignored build scripts: less` and asks you to approve them). This is expected
and **safe to ignore**. Extension.js compiles Less through its own bundler
pipeline, so it does not rely on Less's optional post-install step (which only
sets up Playwright inside Less's own repository and is a no-op for consumers).
Your `.less` and `.module.less` files build and hot-reload the same either way.
Scaffolded projects do not ship a `pnpm.ignoredBuiltDependencies` key, so a
pnpm install of a Less template shows the notice like any other project. npm and
yarn run the script by default and print nothing.
If you hit the notice, you have two equivalent ways to silence it:
* **Skip it (recommended)**: add `pnpm.ignoredBuiltDependencies: ["less"]` to
your `package.json`. This acknowledges the script without running the no-op.
* **Run it**: run `pnpm approve-builds` and select `less`, or add
`pnpm.onlyBuiltDependencies: ["less"]`. Harmless, just unnecessary.
Either way there is no functional difference for typical Extension.js projects.
## Next steps
* Learn more about [CSS Modules](/docs/languages-and-frameworks/css-modules).
* Configure [PostCSS](/docs/integrations/postcss) in your extension.
## Video walkthrough
# Node APIs in browser extensions
Source: https://extension.js.org/docs/languages-and-frameworks/node
Use browser-native APIs first, then selectively polyfill Node core modules when needed. Extension.js targets the browser runtime by default.
Extension.js targets the browser runtime by default. It does not automatically polyfill Node core modules, which keeps bundles small. If a dependency reaches for `buffer`, `stream`, or `os`, you see a resolution error at build time. The sections below explain when to add polyfills and when to choose a browser-native alternative.
## When Node polyfills are a good fit
* A required dependency needs Node core modules in browser runtime.
* You are incrementally migrating code from Node-centric packages.
* You can accept larger bundles for specific runtime capabilities.
## Template examples
### `new-typescript`
Start from a TypeScript baseline when your extension needs explicit bundler/polyfill tuning.
```bash npm theme={null}
npx extension@latest create my-extension --template=newtab-typescript
```
```bash pnpm theme={null}
pnpx extension@latest create my-extension --template=newtab-typescript
```
```bash yarn theme={null}
yarn dlx extension@latest create my-extension --template=newtab-typescript
```
```bash bun theme={null}
bunx extension@latest create my-extension --template=newtab-typescript
```
```bash deno theme={null}
deno run -A npm:extension@latest create my-extension --template=newtab-typescript
```
Repository: [extension-js/examples/newtab-typescript](https://github.com/extension-js/examples/tree/main/examples/newtab-typescript)
### `content-typescript`
Use a content-script TypeScript base when Node-dependent libraries run inside page-injected flows.
```bash npm theme={null}
npx extension@latest create my-extension --template=content-typescript
```
```bash pnpm theme={null}
pnpx extension@latest create my-extension --template=content-typescript
```
```bash yarn theme={null}
yarn dlx extension@latest create my-extension --template=content-typescript
```
```bash bun theme={null}
bunx extension@latest create my-extension --template=content-typescript
```
```bash deno theme={null}
deno run -A npm:extension@latest create my-extension --template=content-typescript
```
Repository: [extension-js/examples/content-typescript](https://github.com/extension-js/examples/tree/main/examples/content-typescript)
### `new-crypto`
See the Web Crypto API (`window.crypto.subtle`) used directly in a new-tab extension, no polyfill.
```bash npm theme={null}
npx extension@latest create my-extension --template=newtab-crypto
```
```bash pnpm theme={null}
pnpx extension@latest create my-extension --template=newtab-crypto
```
```bash yarn theme={null}
yarn dlx extension@latest create my-extension --template=newtab-crypto
```
```bash bun theme={null}
bunx extension@latest create my-extension --template=newtab-crypto
```
```bash deno theme={null}
deno run -A npm:extension@latest create my-extension --template=newtab-crypto
```
Repository: [extension-js/examples/newtab-crypto](https://github.com/extension-js/examples/tree/main/examples/newtab-crypto)
## Default behavior
* Build target is browser-first (`web`).
* Resolution prioritizes browser exports (`browser`, `module`, `main`).
* `crypto`, `path`, and `fs` are mapped to `false`, so they resolve to an empty module. The
build succeeds and the import fails at runtime instead (for example `path.join` is undefined).
* Every other Node core module, including `buffer`, `stream`, `os` and `node:`-prefixed
specifiers, has no fallback and fails the build with `Module not found: Can't resolve`.
So a missing Node API shows up in one of two places, and which one depends on the module.
Add explicit fallbacks below to handle either case.
## Setting up Node polyfills
Use `extension.config.js` (or `.mjs` / `.cjs`) to extend the Rspack configuration and define safe fallbacks.
```js theme={null}
import NodePolyfillPlugin from "node-polyfill-webpack-plugin";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
export default {
config: (config) => {
config.resolve = config.resolve || {};
config.resolve.fallback = {
...(config.resolve.fallback || {}),
crypto: require.resolve("crypto-browserify"),
path: require.resolve("path-browserify"),
fs: false,
};
config.plugins = config.plugins || [];
config.plugins.push(new NodePolyfillPlugin());
return config;
},
};
```
## Install optional polyfill packages
```bash npm theme={null}
npm install -D node-polyfill-webpack-plugin crypto-browserify path-browserify
```
```bash pnpm theme={null}
pnpm add -D node-polyfill-webpack-plugin crypto-browserify path-browserify
```
```bash yarn theme={null}
yarn add -D node-polyfill-webpack-plugin crypto-browserify path-browserify
```
```bash bun theme={null}
bun add -d node-polyfill-webpack-plugin crypto-browserify path-browserify
```
```bash deno theme={null}
deno add -D npm:node-polyfill-webpack-plugin npm:crypto-browserify npm:path-browserify
```
## Extension APIs vs Node APIs
Setting `polyfill: true` in CLI/config enables `webextension-polyfill`, a compatibility layer that gives Chromium browsers access to the `browser.*` API namespace. It does not enable Node core module polyfills.
Use Node polyfills only for libraries that cannot run with standard Web APIs.
## Caveats
* Do not assume filesystem access in extension runtime; keep `fs: false` unless you have a specific browser-safe strategy.
* Prefer Web Crypto (`crypto.subtle`) over large Node crypto shims when possible.
* Polyfills (browser-compatible replacements for Node APIs) increase bundle size and can affect startup time in extension pages and content scripts.
* Some packages using `node:` specifiers may need explicit fallback handling.
## Next steps
* Learn how to customize [Rspack configuration](/docs/features/rspack-configuration).
* Learn how to manage [Extension configuration](/docs/features/extension-configuration).
## Video walkthrough
# Preact for browser extensions
Source: https://extension.js.org/docs/languages-and-frameworks/preact
Ship smaller extension UI bundles with Preact while keeping a React-like DX. Extension.js auto-configures JSX transforms and compat aliases.
Ship smaller extension UI bundles while keeping a React-like developer experience and fast local iteration.
Extension.js detects Preact from your dependencies. It configures JSX/TSX transforms and React compatibility aliases automatically. Your React imports map to Preact through these aliases. In development, edits apply through live reload: the changed surface reloads rather than hot-swapping components in place.
## When Preact is a good fit
* You want smaller UI bundle size for popup/sidebar/new tab surfaces.
* You like React-style components but want a lighter runtime.
* You are optimizing extension startup and UI responsiveness on lower-end devices.
## Template examples
### `new-preact`
Ship a lighter new-tab UI with Preact and React-compatible ergonomics.
```bash npm theme={null}
npx extension@latest create my-extension --template=newtab-preact
```
```bash pnpm theme={null}
pnpx extension@latest create my-extension --template=newtab-preact
```
```bash yarn theme={null}
yarn dlx extension@latest create my-extension --template=newtab-preact
```
```bash bun theme={null}
bunx extension@latest create my-extension --template=newtab-preact
```
```bash deno theme={null}
deno run -A npm:extension@latest create my-extension --template=newtab-preact
```
Repository: [extension-js/examples/newtab-preact](https://github.com/extension-js/examples/tree/main/examples/newtab-preact)
### `content-preact`
Inject a compact Preact UI into page content using content scripts.
```bash npm theme={null}
npx extension@latest create my-extension --template=content-preact
```
```bash pnpm theme={null}
pnpx extension@latest create my-extension --template=content-preact
```
```bash yarn theme={null}
yarn dlx extension@latest create my-extension --template=content-preact
```
```bash bun theme={null}
bunx extension@latest create my-extension --template=content-preact
```
```bash deno theme={null}
deno run -A npm:extension@latest create my-extension --template=content-preact
```
Repository: [extension-js/examples/content-preact](https://github.com/extension-js/examples/tree/main/examples/content-preact)
## Usage with an existing extension
Add Preact to an existing extension with the steps below.
### Installation
Install the required dependencies:
```bash npm theme={null}
npm install preact
```
```bash pnpm theme={null}
pnpm add preact
```
```bash yarn theme={null}
yarn add preact
```
```bash bun theme={null}
bun add preact
```
```bash deno theme={null}
deno add npm:preact
```
Preact ships its own TypeScript types, so you do not need a separate `@types/preact` package.
`preact` is the only package you add. Extension.js resolves `preact/compat`, `preact/test-utils`, and the JSX runtimes from your project and builds the alias map from whatever it finds, so there is no extra package to install.
### Configuration
Extension.js expects Preact files to use the following file extensions:
* If you do not enable TypeScript: `*.jsx`
* If you enable TypeScript: `*.tsx`
## Development behavior
When Extension.js detects Preact, it configures:
* Compatibility aliases (for example, `react` → `preact/compat`).
* JSX handling tuned for Preact.
* Live reload in development: when a file changes, the affected surface reloads and remounts.
Unlike React, Preact does not currently get fast refresh (state-preserving
hot updates). The upstream `@rspack/plugin-preact-refresh` runtime is
incompatible with the Rspack version Extension.js ships, so Extension.js
disables it rather than break dev mode. Your app still updates on every
edit, and component state is just not preserved across edits. Fast refresh
returns once the upstream plugin is fixed.
### Troubleshooting
* **Component state resets on edit:** Expected for now, because Preact uses live reload, not fast refresh (see above).
* **No Preact integration detected:** Confirm `preact` appears in `dependencies` or `devDependencies`.
## Usage examples
### In a new tab extension
To use Preact in a new tab extension, include it as a `