Files
camoufox/typescript
Jake WriterandClaude Opus 5.5 e92caec332 CI: release npm with PyPI, split the patch guards by kind, run memory growth on every PR (#789)
* ci: split the patch guards by kind, and run memory growth on every PR

Patch guards: one 11-minute job ran all 26 guards and the Playwright
skiplist audit. Whether the patches apply is the build's check; the
guards test that what they do still works, and fall into three kinds,
now three jobs beside the skiplist audit:

  spoofing    a spoofed value still reaches the page and holds together
  automation  Playwright stays invisible to the page and never deadlocks it
  parity      what a page, or the OS, can observe matches stock Firefox

Each writes its own suite (patch_guards_<group>), so a failure names the
kind that broke. GROUPS in ci/run_patch_guards.py assigns every guard to
exactly one, and a self-test fails on a guard in none. One job id with a
matrix, so everything that needs patch-guards is unchanged.

Memory growth: ~38 minutes in one process kept it on the schedule and
out of the gate. ci.run_native --shard i/n runs every n-th collected
test, and the growth job is a 7-way matrix -- one test per runner, about
six minutes each -- on every pull request, required by the summary and
the gate. summarize.py already folds <suite>-<i>of<n> results back into
one suite, as it does for Playwright.

CONTRIBUTING.md now says why the stealth check skips on a fork pull
request: GitHub gives secrets only to branches in this repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(release): publish to npm after PyPI, from the same commit

The two launchers ship at one version, but were released by two
unrelated, hand-started workflows, so npm could get a release PyPI did
not. "Publish to pypi" is now the one place a release starts:

1. it calls publish-npm.yml as a dry run -- every check, the build, the
   pack check and `npm publish --dry-run` -- so a broken npm package stops
   the release before anything is uploaded;
2. it uploads to PyPI;
3. its success triggers publish-npm.yml (workflow_run), which publishes
   the commit PyPI was released from.

publish-npm.yml stays the file that publishes, because npm's trusted
publisher is tied to its name. Started by hand it only retries the npm
half, and refuses unless PyPI already has the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guards): contentaccessible-parity reads its probe's marked result line

The live probe runs in a child process and the parent parsed its whole
stdout as JSON. On a machine whose cache has no addons yet, the first
Camoufox launch downloads uBlock Origin and prints its progress to
stdout first, so the parse failed ("Expecting value: line 2 column 1").
It only ever passed because another guard launched Camoufox earlier in
the same job; split into its own leg, it ran first on a fresh runner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(guards): group the three guards main added since the split

addons-install-once, viewport-no-rdm and worker-config-reads landed on main
after the groups were drawn; the one-group-each self-test caught them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guards): gfx-probes gives a blocklisted launch a second try

The blocklist signature means gfxInfo is empty. Missing probes cause that on
every launch; a present glxtest that fails or times out on a loaded runner
causes it once in a while, which failed stock parity on this PR. Relaunch once
before failing, and print what the probes wrote to stderr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:42:47 +00:00
..

camoufox (TypeScript)

This is the JavaScript/TypeScript client for Camoufox. It is a port of the Python wrapper in ../pythonlib — it does not shell out to Python.

The two launchers are twins: they read the same properties.json, write the same chunked CAMOU_CONFIG, share the same browser install directory, and ship the same fingerprint presets, font/voice lists and GeoIP configuration. They draw the same identities too: fingerprints and WebGL devices come from a TypeScript port of fpgen using the same pinned model, and the per-identity draws (fonts, voices, GPU, media devices, noise seeds) use a bit-exact port of CPython's random, so a pinned identity presents identically from either language.

Installation

npm install @camoufox/camoufox playwright-core
# then download the browser
npx camoufox fetch

playwright-core is a peer dependency — bring your own version (<1.63, the same ceiling as the Python package). Node 22.15 or newer is required.

Usage

import { Camoufox } from "@camoufox/camoufox";

const browser = await Camoufox({
    // any Camoufox option, plus any Playwright Firefox launch option
    headless: true,
    os: "windows",
    geoip: true,
});

const page = await browser.newPage(); // a Playwright Page
await page.goto("https://example.com");
await browser.close();

Persistent profiles

const context = await Camoufox({ user_data_dir: "./profiles/alice" });
const page = await context.newPage();

Per-context identities

NewContext() gives each context its own fingerprint — real preset or fpgen-synthesised — with its own audio noise seed. The values are applied through addInitScript, so the setters self-destruct before any page script runs.

import { Camoufox, NewContext } from "@camoufox/camoufox";

const browser = await Camoufox({ headless: true });
const context = await NewContext(browser, {
    os: "macos",
    proxy: { server: "http://proxy:8080", username: "u", password: "p" },
});

When a proxy is given and no webrtc_ip/timezoneId is, both are resolved from the proxy's exit IP. If that lookup fails, NewContext() throws InvalidIP rather than open a context that would show the host's values.

Server mode

import { launchServer } from "@camoufox/camoufox";

const server = await launchServer({ headless: true, port: 9222 });
console.log(server.wsEndpoint());

Persistent contexts are not servable — Playwright's launchServer can only expose a pre-launched Browser.

Building launch options yourself

import { launchOptions } from "@camoufox/camoufox";
import { firefox } from "playwright-core";

const browser = await firefox.launch(await launchOptions({ os: "linux" }));

Options

Every option from the Python launch_options() is supported, with the same snake_case names: os, config, block_images, block_webrtc, block_webgl, disable_coop, webgl_config, geoip, geoip_db, humanize, locale, addons, fonts, custom_fonts_only, exclude_addons, screen, window, fingerprint, fingerprint_preset, ff_version, headless, main_world_eval, allow_addon_new_tab, executable_path, browser, firefox_user_prefs, proxy, enable_cache, args, env, i_know_what_im_doing, debug, virtual_display, pin_cpu_cores. Anything else is passed straight through to Playwright.

The returned launch options use Playwright's camelCase keys (executablePath, firefoxUserPrefs) rather than Python's snake_case. As in Python, headless: "virtual" is handled by Camoufox(), NewBrowser() and launchServer(), not by launchOptions().

CLI

camoufox sync                     # refresh the version catalogue
camoufox fetch [version]          # install the active or a specific version
camoufox set [specifier]          # pin a version or channel; no specifier opens a picker
camoufox set --geoip              # pick a GeoIP source
camoufox list [installed|all]     # list versions
camoufox remove [version]         # remove one version, or everything (--select to pick)
camoufox active                   # print the active version
camoufox path                     # print the install directory
camoufox version                  # version / storage info
camoufox test [url]               # open the Playwright inspector
camoufox server                   # launch a Playwright server

The commands and pickers match the Python CLI. The one exception is gui, a PySide6 desktop app that only the Python package provides.

Development

The tests need a Python with pythonlib next to them, at the repo root (or point CAMOUFOX_PYTHON at one):

python3.14 -m venv .venv                                           # repo root
.venv/bin/pip install -r ci/requirements.txt -e pythonlib
.venv/bin/python scripts/pin-fpgen-model.py
cd typescript
pnpm install
pnpm build       # tsc -> dist/, then copy pythonlib's data files into dist/data-files
pnpm test        # records the golden fixtures from pythonlib, then vitest
pnpm check       # biome lint + format
pnpm typecheck   # tsc --noEmit

The data files (presets, fonts, voices, territoryInfo.xml, ...) are read from pythonlib/camoufox/, the only copy in the repo; DATA_FILES in src/paths.ts lists them, and the build copies them into the package.

Parity with pythonlib

The golden tests are what keep the two launchers twins. Before the suite runs, tests/golden-setup.ts runs the scripts under scripts/golden/, which put the Python code through hundreds of fixed inputs and record its output in tests/fixtures/ (git-ignored); the TS tests must reproduce it exactly -- launch_options() byte for byte, including the CAMOU_CONFIG blob. A pythonlib change that is not mirrored here fails pnpm test. Use Python 3.14: pySum() follows its sum(), and on 3.12/3.13 that one test skips.

The end-to-end suite launches a real browser through both launchers and compares what a page sees:

CAMOUFOX_E2E=1 CAMOUFOX_EXECUTABLE=/path/to/camoufox-bin pnpm test tests/e2e.test.ts

Releasing

The npm package and pythonlib are released together, at one version, from one place: Actions → Publish to pypi. That run first calls .github/workflows/publish-npm.yml as a dry run -- type check, lint, tests, build, and scripts/check-pack.mjs (the version must equal pythonlib's; every data file must be in the tarball; the tarball must install and import in an empty project) -- then uploads to PyPI, and its success triggers publish-npm.yml to publish the same commit to npm with trusted publishing (no token is stored). Starting publish-npm.yml by hand only retries the npm half, and it refuses unless PyPI already has the version.

Licence

MIT, like the Python package (LICENSE); the browser itself is MPL-2.0. The package contains ports of fpgen (Apache-2.0), CPython's random and NumPy's pairwise summation; their notices are in THIRD_PARTY_NOTICES.md, which ships with it.