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.
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.
Dev command capabilities
Usage
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 for the rest.Arguments and flags
Two deprecated aliases are hidden from
--help but still work:
--wait-format <pretty|json>maps onto--outputand warns once on stderr. Migrate scripts to--output.--authorand--author-modemap onto--debug.
Safari flags
These flags apply tosafari and webkit-based targets only. Passing any of them with another target exits with E_INVALID_OPTION, so a typo never no-ops silently.
For Safari targets,
dev also runs a toolchain preflight before the first bundle. A missing Xcode fails fast with E_SAFARI_TOOLCHAIN.
From 4.1.20, a Safari session is a full dev session. Once you enable the extension in Safari Settings, extension logs reads it, the control bridge attaches, and every save reloads the extension in Safari. See Building Safari extensions.
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)
Whendev runs, Extension.js emits machine-readable metadata under:
dist/extension-js/<browser>/ready.jsondist/extension-js/<browser>/events.ndjson(newline-delimited JSON)
ready.json as the readiness contract:
status: "starting"while bootingstatus: "ready"when the build compiledstatus: "error"for startup/compile failuresstatus: "stopped"after the session shut down, so a dead session never advertisesreadyruntime: "attached"(withexecutorAttachedAt) once the service worker connected. Act verbs should wait for this, not forreadyruntime: "detached"(withexecutorDetachedAt) from 4.1.20, once the last connected extension context disconnects.executorAttachedAtstays as provenance, and the value goes back to"attached"on reconnectrunIduniquely identifies a runtime sessionstartedAtmarks the runtime session start timestampcommandnames the producing command (dev,start,preview, orbuild)toolchainVersion,extensionName, andextensionVersionrecord which Extension.js version produced the tree, for which extension. The file doubles as a build receipt after the terminal scrollback is goneportis the bound dev server port, andhostis the connectable host clients dial (see bind host vs. connectable host)controlPort/instanceIdlocate the control bridge used byextension logsand the act verbscdpPort(Chromium) andrdpPort(Gecko) expose the browser debugging portsprofilePath,browserPid, andextensionIdare stamped by the browser launcher after launch
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: 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:--no-browser is the run mode for headless, CI, and remote work, and it is the flag the Playwright end-to-end workflow 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.executeScriptwith 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.
--no-browser behaves like a normal dev session for headless, continuous integration (CI), and remote/dev-container workflows: load the built dist/<browser> 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:
- Run
extension dev --no-browseras a long-lived process. - Run
extension dev --wait --browser=<browser>as the readiness gate. - 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.
From 4.1.21, a --wait that reads ready.json while the producer is still writing it treats the half-written file as one more transient state and keeps polling. Up to 4.1.20, one torn read failed the wait with a JSON parse error. A file that never parses still ends in E_READY_TIMEOUT, and the timeout message names the cause: The last read of the file failed to parse as JSON, followed by the parser’s own error.
Machine output with --output json
--output json prints a schema-1 envelope on stdout:
- A plain
devrun prints onestatus: "started"frame as the first stdout line. It carries the project path, browser list, requested port, and the dev serverpid.devnever terminates on its own, so no result frame follows, and unlikebuilda long-running session keeps writing its human progress lines to stdout after that frame. Parse the first line, then readready.jsonfor the live state. - A
dev --waitrun prints onestatus: "ready"frame on success. Itsvalue.resultsarray carries the full ready contract per browser. - Failures print one
ok: falseframe with anerror.code(for exampleE_READY_TIMEOUT) before the process exits1.
--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.Shared global options
Also supports Global flags.Monorepo and workspace roots
You can pointdev (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:
pnpm workspace members install from the root
From 4.1.18, when the project is a member of a pnpm workspace and its dependencies are missing, the automatic install runs from the workspace root instead of the package folder. The install is filtered to the member and its workspace dependencies:pnpm install at the root. The session prints one info line before the install runs:
Examples
Running a local extension
Running a remote extension from GitHub
Pass a GitHub tree URL as the argument to develop a remote extension locally:Running in Firefox
Running in multiple browsers in sequence
Running inside Docker or a dev container
When you run inside Docker, dev containers, or GitHub Codespaces, bind the dev server to0.0.0.0 so the host machine can reach it:
--port 0 to let the OS choose an available port automatically:
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.0binds every interface, but0.0.0.0is not a connectable address. Extension.js automatically advertises127.0.0.1to 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-hostwith 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.
--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
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, sobrowser.*calls work in Chromium-based browsers. Pass--no-polyfillwhen you want the raw bundle. - Automation reliability: Treat
devas the watch-mode companion (--no-browser+dev --wait). Treatstartas the production companion (--no-browser+start --wait). Use--output=jsonfor scripts and CI automation.
Next steps
- Build production artifacts with
build. - Validate production launch flow with
start. - Review browser targeting with Browser-specific manifest fields.
- Configure shared defaults in
extension.config.js. - Review configuration env loading behavior in Environment variables.

