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.
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 forreadyrunIduniquely 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.
--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.
Machine output with --output json
--output json prints schema-1 envelopes on stdout, one JSON object per line:
- A plain
devrun prints onestatus: "started"frame at startup. It carries the project path, browser list, requested port, and the dev serverpid.devnever terminates on its own, so no result frame follows. 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.
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:
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.

