diff --git a/docs/05-extension-spec.md b/docs/05-extension-spec.md index 4c58fae..d4ab6a7 100644 --- a/docs/05-extension-spec.md +++ b/docs/05-extension-spec.md @@ -93,11 +93,16 @@ interface Transport { `browser.runtime.connectNative("com.velox.host")` → `velox-nmhost` → Unix socket. Manifest installed to **all** of these, because the right one depends on how Firefox was installed: -- `~/.mozilla/native-messaging-hosts/com.velox.host.json` (deb/tarball) -- `~/snap/firefox/common/.mozilla/native-messaging-hosts/com.velox.host.json` (snap) -- `/usr/lib/mozilla/native-messaging-hosts/com.velox.host.json` (system-wide) +- `~/.mozilla/native-messaging-hosts/com.velox.host.json` (deb/tarball **and snap** — + spike S1 proved snap Firefox reads the real home here, not `$SNAP_USER_COMMON`; see + `docs/adr/0003-native-messaging-under-snap.md`) +- `/usr/lib/mozilla/native-messaging-hosts/com.velox.host.json` (system-wide; deb/tarball + only — snap Firefox does not read it, per S1) - `~/.var/app/org.mozilla.firefox/.mozilla/native-messaging-hosts/` (flatpak) +The host binary runs **outside** the snap sandbox with the real `$HOME` and real +`$XDG_RUNTIME_DIR`, so it reaches `veloxd`'s Unix socket directly (ADR 0003 §Q2). + ### B. `WebSocketTransport` (fallback, guaranteed to work under snap confinement) `ws://127.0.0.1:` where the port is discovered by trying a small fixed range and verifying a `session.hello` handshake — the extension cannot read diff --git a/docs/adr/0003-native-messaging-under-snap.md b/docs/adr/0003-native-messaging-under-snap.md new file mode 100644 index 0000000..754b54a --- /dev/null +++ b/docs/adr/0003-native-messaging-under-snap.md @@ -0,0 +1,174 @@ +# ADR 0003 — Native messaging under snap Firefox (spike S1) + +**Status:** accepted · **Date:** 2026-09-10 · **Lane:** EXT + +## Context + +`docs/06` R1 flags snap-confined Firefox as a HIGH risk to the extension transport: a +native-messaging (NM) host that lives outside the snap has a long history of not launching, +and even when it does, of running under the snap's constraints. R1's decision rule needs an +empirical answer to two questions before M2: + +1. Which of the four NM manifest locations can snap Firefox actually launch a host from? +2. Can that launched host reach the real `$XDG_RUNTIME_DIR`, where `veloxd` puts its socket? + +S1's brief assumes a clean 26.04 VM. No VM was available; the spike was run on the target +machine itself. "Clean VM" is read here as *uncontaminated state* — so the machine's +pre-existing NM state is recorded below in place of the guarantee a fresh image would have +given, and every change made was reversible and has been reverted. + +## What was tested + +- Throwaway host-id family `com.example.s1probe.{realhome,snapcommon,usrlib}`, one id per + candidate location so precedence is not a variable. Trivial Python echo hosts under + `/home/sami/vdm-s1-probe/` that, on launch, record their own `$HOME` / `$XDG_RUNTIME_DIR` + and attempt to `bind()` an `AF_UNIX` socket both in `$XDG_RUNTIME_DIR` and in the literal + real `/run/user/1000`. +- Throwaway MV2 extension (id `s1probe@velox.test`, permissions `nativeMessaging`, + `storage`, `http://127.0.0.1/*`) that calls `runtime.sendNativeMessage` for all three ids + on load and POSTs the outcomes to a localhost collector. No remote code. +- Driven by `web-ext run` 10.6.0 against **snap-confined** headless Firefox. `web-ext` by + default `exec`s the inner binary `/snap/firefox/8763/usr/lib/firefox/firefox` directly, + which bypasses confinement; every result below is from runs forced through + `exec snap run firefox "$@"`, with `apparmor: snap.firefox.firefox (enforce)` verified on + the launched browser process. +- The browser's own confined view was captured separately via `snap run --shell firefox`. + +## Results + +| Manifest location | Host launched? | Host `$HOME` | Host `$XDG_RUNTIME_DIR` | Bind in real `/run/user/1000`? | +|---|---|---|---|---| +| `~/.mozilla/native-messaging-hosts/` (real home) | **yes** (2 runs) | `/home/sami` (real) | `/run/user/1000` (real) | **yes** | +| `~/snap/firefox/common/.mozilla/native-messaging-hosts/` (`$SNAP_USER_COMMON`) | **no** (3 runs) — `"No such native application"` | — | — | — | +| `/usr/lib/mozilla/native-messaging-hosts/` (system-wide) | **no** (1 run) — `"No such native application"`, manifest confirmed on disk `root:root 0644` | — | — | — | +| `~/.var/app/org.mozilla.firefox/.mozilla/native-messaging-hosts/` (flatpak) | **N/A** — Firefox here is the snap, not flatpak; tree absent | — | — | — | + +**Q1 — which location:** only the real `~/.mozilla/native-messaging-hosts/`. Corroborated +by the machine's working 1Password integration: its manifest sits in that same directory +and its host socket `1Password-BrowserSupport.sock` is present in the real `/run/user/1000`. +The `$SNAP_USER_COMMON` path — the one `docs/05` §4.A currently calls *the* snap location — +is not read by Firefox 154 snap. + +**Q2 — can the host reach `$XDG_RUNTIME_DIR`:** yes. Firefox's snap launches NM hosts +**outside** the sandbox. The host inherits the real, unconfined environment (real `$HOME`, +real `$XDG_RUNTIME_DIR=/run/user/1000`) and can `bind()`/`connect()` an `AF_UNIX` socket +there. This is the opposite of the *browser* process's own view. Via +`snap run --shell firefox`: + +- browser: `apparmor: snap.firefox.firefox (enforce)`, `$HOME=$SNAP_USER_COMMON`, + `$XDG_RUNTIME_DIR=/run/user/1000/snap.firefox`; +- from inside: can bind under the snap-private `$XDG_RUNTIME_DIR`; **EACCES** binding or + writing under the real `/run/user/1000`; **EACCES** even listing + `~/.mozilla/native-messaging-hosts/`. + +So `veloxd`'s socket at `$XDG_RUNTIME_DIR/velox/velox.sock` (real) is reachable by the NM +host, though it would be unreachable from inside the browser sandbox. + +### Machine state recorded in lieu of a clean VM + +- Ubuntu 26.04.1 LTS, kernel 7.0.0-31-generic, snapd 2.76.3, `snap debug confinement` = + `strict`, bare metal (not a container). +- Firefox: snap, track `latest/stable/ubuntu-26.04`, **rev 8763**, version **154.0-1**, + publisher `mozilla`, last refreshed 15 days before the spike. +- `snap connections firefox`: `home`, `network`, `network-bind`, `removable-media` + connected; `personal-files[dot-mozilla-firefox]` connected but scoped **read-only to + `$HOME/.mozilla/firefox`** (profile import only); the snap declares **no + `native-messaging` plug**. +- Legacy-home mode (`MOZ_LEGACY_HOME=1`); active profile + `~/snap/firefox/common/.mozilla/firefox/utz2ej6e.default`. Real `~/.mozilla/firefox/` + does not exist. +- Pre-existing NM state: `~/.mozilla/native-messaging-hosts/` contained exactly + `com.1password.1password.json`. `~/snap/firefox/common/.mozilla/native-messaging-hosts/`, + `/usr/lib/mozilla/`, and the flatpak tree did **not** exist. All test artifacts in those + locations were removed afterward; `~/.mozilla/native-messaging-hosts/` was left with only + the pre-existing 1Password file. + +## Decision + +Native messaging **works** on snap Firefox on this configuration, but narrowly: the +manifest must be in the user's real `~/.mozilla/native-messaging-hosts/`. Applying R1's +decision rule: + +- **`WebSocketTransport` stays the primary, default path.** It works unconditionally + (`network-bind` connected), has no per-flavour packaging edge cases, and R1 already + mandates it as the guaranteed path. EXT builds it first. +- **`NativeTransport` ships as an opportunistic upgrade, not the default.** `velox-nmhost` + + manifest are still shipped for deb/tarball/flatpak Firefox and for snap Firefox where + the manifest lands in the right place. +- For **snap** Firefox the manifest goes to **`~/.mozilla/native-messaging-hosts/com.velox.host.json`**, + not the `~/snap/...` path. The host binary may live anywhere the unconfined launcher can + `exec` (`/usr/lib/velox/…`, `/opt/velox/…`); it runs unconfined. +- The NM host, being unconfined with the real `$XDG_RUNTIME_DIR`, connects straight to + `veloxd`'s Unix socket. No cross-namespace bridge is needed on this configuration. + +### Follow-ups + +- **docs/05 §4** — corrected in the same commit as this ADR: the snap manifest goes to + `~/.mozilla/native-messaging-hosts/`, and `/usr/lib/...` is deb/tarball-only. +- **PKG/QA** (open, not EXT's files): the `.deb` postinst must install the snap NM + manifest per-user under `~/.mozilla/native-messaging-hosts/` (postinst runs as root — + needs a real-user enumeration or a first-run/user-systemd step) and must **not** treat + `/usr/lib/mozilla/native-messaging-hosts/` as covering snap Firefox. The installer's + "detect which Firefox is in use and say so" requirement (R1) stands. + +## Alternatives rejected + +- **Manifest in `$SNAP_USER_COMMON/.mozilla/native-messaging-hosts/`.** Intuitive — it is + "inside the snap" — but empirically not read by Firefox 154 snap. +- **`/usr/lib/mozilla/native-messaging-hosts/` as the one system-wide install for every + Firefox flavour.** Does not work for snap Firefox. Retained only for deb/tarball Firefox. +- **Make `NativeTransport` the default when a snap is detected.** The WS path is strictly + simpler to get right across snap/deb/flatpak, and R1 already requires it as the + guaranteed path. NM remains an optimization that the runtime picker in + `transport/index.ts` may prefer *when its handshake actually succeeds*, never on + detection alone. + +## Reproduction + +Self-contained; run in a scratch dir `$D` (all files outside any snap-hidden path so the +sandbox can read them). Takes ~2 min. + +1. **Confinement wrapper** — `web-ext` otherwise `exec`s the inner binary directly and + bypasses the sandbox: + + ```sh + printf '#!/bin/sh\nexec snap run firefox "$@"\n' > "$D/ffwrap.sh" && chmod +x "$D/ffwrap.sh" + ``` + +2. **Probe host** `$D/s1host.py` (`chmod +x`): on launch, append pid + `os.environ` + `HOME`/`XDG_RUNTIME_DIR` to `$D/s1host.log`; try `socket.socket(AF_UNIX).bind()` at + both `os.path.join(os.environ["XDG_RUNTIME_DIR"], "s.sock")` and the literal + `/run/user//s.sock`; then do the native-messaging handshake (read 4-byte LE + length + JSON on stdin, write the same framing back) echoing the probe results. + +3. **Manifest** — one per location under test, each `{"name": "", "type": "stdio", + "path": "$D/s1host.py", "allowed_extensions": ["s1probe@velox.test"]}`, installed to: + `~/.mozilla/native-messaging-hosts/`, `~/snap/firefox/common/.mozilla/native-messaging-hosts/`, + and (with `sudo`) `/usr/lib/mozilla/native-messaging-hosts/`. Use a distinct `` + per location so precedence is not a variable. + +4. **Probe extension** `$D/ext/` — MV2, `browser_specific_settings.gecko.id + = "s1probe@velox.test"`, permissions `["nativeMessaging","http://127.0.0.1/*"]`, + background script that calls `browser.runtime.sendNativeMessage(, {probe:1})` for + every id and POSTs `{id: {ok, resp|error}}` to a localhost collector (`python3 -m + http.server` handler writing the body to a file). + +5. **Run**, confined and headless: + + ```sh + npx --yes web-ext@10 run --source-dir="$D/ext" --firefox="$D/ffwrap.sh" \ + --firefox-profile="$D/ff-profile" --profile-create-if-missing --keep-profile-changes \ + --no-input --no-reload --args=--headless + ``` + + Confirm confinement with `cat /proc//attr/current` → `snap.firefox.firefox + (enforce)`. Read the collector file and `$D/s1host.log` for the result. + +6. **Browser-side view** (Q2's other half): `snap run --shell firefox -c 'echo $HOME; + echo $XDG_RUNTIME_DIR; python3 -c "import socket,os; + socket.socket(socket.AF_UNIX).bind(\"/run/user/%d/x.sock\" % os.getuid())"'` — expect + `EACCES` on the real `/run/user/`. + +7. **Clean up**: remove every manifest installed in step 3 (the `/usr/lib` one needs + `sudo`; `rmdir` `/usr/lib/mozilla{,/native-messaging-hosts}` if you created them), + delete the test profile from `~/snap/firefox/common/.mozilla/firefox/`, and `rm -rf $D`. diff --git a/extension/README.md b/extension/README.md index a853239..b6f01ca 100644 --- a/extension/README.md +++ b/extension/README.md @@ -1,2 +1,37 @@ Owner: lane EXT. See ../docs/agents/AGENT-EXT.md and ../docs/05-extension-spec.md. Firefox MV3, TypeScript. Zero download logic. + +## Layout + +``` +src/background/transport/ Transport interface + WebSocket and native-messaging impls, + runtime picker. See docs/adr/0003 for why there are two. +src/shared/protocol/ GENERATED from ../contracts — never hand-edit. +tests/ vitest; webextension-polyfill is mocked in tests/setup.ts. +``` + +## Develop + +``` +npm ci +npm run typecheck # tsc --noEmit, strict +npm test # vitest run +npm run lint # web-ext lint (AMO rules) — needs manifest.json +``` + +Build against `tools/mockd` over the WebSocket transport; `veloxd` is not required. + +## Transport quick start + +```ts +import { createTransport } from './src/background/transport/index.js'; + +const t = await createTransport(); // reads the Options override; default 'auto' +t.onStateChange((s) => renderDot(s)); +const rules = await t.call('capture.getRules', {}); +``` + +`createTransport` resolves with a live, self-reconnecting transport. When `veloxd` is not +up yet it still resolves (WebSocket, status `disconnected`, retrying) — callers render the +red dot. It rejects only when the user explicitly forced native messaging and that failed. +Capture code treats every `call()` rejection as fail-open. diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..4d165e7 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,34 @@ +{ + "manifest_version": 3, + "name": "Velox", + "version": "0.1.0", + "description": "Hands Firefox downloads to the Velox download manager. Collects the URL, headers and cookies; the daemon does the rest.", + + "browser_specific_settings": { + "gecko": { + "id": "velox@velox.download", + "strict_min_version": "128.0", + "data_collection_permissions": { + "required": ["none"] + } + } + }, + + "background": { + "scripts": ["dist/background.js"], + "type": "module" + }, + + "permissions": [ + "webRequest", + "webRequestBlocking", + "downloads", + "cookies", + "contextMenus", + "storage", + "notifications", + "nativeMessaging" + ], + + "host_permissions": [""] +} diff --git a/extension/package-lock.json b/extension/package-lock.json new file mode 100644 index 0000000..225a50e --- /dev/null +++ b/extension/package-lock.json @@ -0,0 +1,4915 @@ +{ + "name": "@velox/extension", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@velox/extension", + "version": "0.0.0", + "devDependencies": { + "@types/firefox-webext-browser": "^120.0.4", + "@types/ws": "^8.5.12", + "esbuild": "^0.24.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0", + "web-ext": "^8.3.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@devicefarmer/adbkit": { + "version": "3.3.8", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@devicefarmer/adbkit-logcat": "^2.1.2", + "@devicefarmer/adbkit-monkey": "~1.2.1", + "bluebird": "~3.7", + "commander": "^9.1.0", + "debug": "~4.3.1", + "node-forge": "^1.3.1", + "split": "~1.0.1" + }, + "bin": { + "adbkit": "bin/adbkit" + }, + "engines": { + "node": ">= 0.10.4" + } + }, + "node_modules/@devicefarmer/adbkit-logcat": { + "version": "2.1.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@devicefarmer/adbkit-monkey": { + "version": "1.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.10.4" + } + }, + "node_modules/@devicefarmer/adbkit/node_modules/debug": { + "version": "4.3.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/espree": { + "version": "9.6.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@fluent/syntax": { + "version": "0.19.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@fregante/relaxed-json": { + "version": "2.0.0", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@mdn/browser-compat-data": { + "version": "7.1.5", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "dev": true, + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/firefox-webext-browser": { + "version": "120.0.5", + "resolved": "https://registry.npmjs.org/@types/firefox-webext-browser/-/firefox-webext-browser-120.0.5.tgz", + "integrity": "sha512-imn8ecga0HQWcOSvxy9i0lD+7vpHgkj1NVLvXS1lNHqHt03Z4QwazeEdoWe+C9JXpmVyKiRanb+z9D/w001tRg==", + "dev": true + }, + "node_modules/@types/minimatch": { + "version": "3.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.2", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/addons-linter": { + "version": "7.20.0", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@fluent/syntax": "0.19.0", + "@fregante/relaxed-json": "2.0.0", + "@mdn/browser-compat-data": "7.1.5", + "addons-moz-compare": "1.3.0", + "addons-scanner-utils": "9.13.0", + "ajv": "8.17.1", + "chalk": "4.1.2", + "cheerio": "1.0.0-rc.12", + "columnify": "1.6.0", + "common-tags": "1.8.2", + "deepmerge": "4.3.1", + "eslint": "8.57.1", + "eslint-plugin-no-unsanitized": "4.1.4", + "eslint-visitor-keys": "4.2.1", + "espree": "10.4.0", + "esprima": "4.0.1", + "fast-json-patch": "3.1.1", + "image-size": "2.0.2", + "json-merge-patch": "1.0.2", + "pino": "9.9.5", + "semver": "7.7.2", + "source-map-support": "0.5.21", + "upath": "2.0.1", + "yargs": "17.7.2", + "yauzl": "2.10.0" + }, + "bin": { + "addons-linter": "bin/addons-linter" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/addons-moz-compare": { + "version": "1.3.0", + "dev": true, + "license": "MPL-2.0" + }, + "node_modules/addons-scanner-utils": { + "version": "9.13.0", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@types/yauzl": "2.10.3", + "common-tags": "1.8.2", + "first-chunk-stream": "3.0.0", + "strip-bom-stream": "4.0.0", + "upath": "2.0.1", + "yauzl": "2.10.0" + }, + "peerDependencies": { + "body-parser": "1.20.3", + "express": "4.21.2", + "node-fetch": "2.6.11", + "safe-compare": "1.1.4" + }, + "peerDependenciesMeta": { + "body-parser": { + "optional": true + }, + "express": { + "optional": true + }, + "node-fetch": { + "optional": true + }, + "safe-compare": { + "optional": true + } + } + }, + "node_modules/adm-zip": { + "version": "0.5.18", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-differ": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/array-union": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "dev": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/atomically": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "dev": true, + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/boxen": { + "version": "8.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/boxen/node_modules/type-fest": { + "version": "4.41.0", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chrome-launcher": { + "version": "1.2.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^2.0.1" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.cjs" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/columnify": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "strip-ansi": "^6.0.1", + "wcwidth": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/commander": { + "version": "9.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "dev": true, + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "dev": true, + "license": "ISC" + }, + "node_modules/configstore": { + "version": "7.1.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "atomically": "^2.0.3", + "dot-prop": "^9.0.0", + "graceful-fs": "^4.2.11", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debounce": { + "version": "1.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-prop": { + "version": "9.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop/node_modules/type-fest": { + "version": "4.41.0", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "dev": true, + "license": "MIT" + }, + "node_modules/es6-error": { + "version": "4.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-goat": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-no-unsanitized": { + "version": "4.1.4", + "dev": true, + "license": "MPL-2.0", + "peerDependencies": { + "eslint": "^8 || ^9" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/espree": { + "version": "9.6.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-redact": { + "version": "3.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.3", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/firefox-profile": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "adm-zip": "~0.5.x", + "fs-extra": "^11.2.0", + "ini": "^4.1.3", + "minimist": "^1.2.8", + "xml2js": "^0.6.2" + }, + "bin": { + "firefox-profile": "lib/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/first-chunk-stream": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "dev": true, + "license": "ISC" + }, + "node_modules/fs-extra": { + "version": "11.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/fx-runner": { + "version": "1.4.0", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "commander": "2.9.0", + "shell-quote": "1.7.3", + "spawn-sync": "1.0.15", + "when": "3.7.7", + "which": "1.2.4", + "winreg": "0.0.12" + }, + "bin": { + "fx-runner": "bin/fx-runner" + } + }, + "node_modules/fx-runner/node_modules/commander": { + "version": "2.9.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-readlink": ">= 1.0.0" + }, + "engines": { + "node": ">= 0.6.x" + } + }, + "node_modules/fx-runner/node_modules/isexe": { + "version": "1.1.2", + "dev": true, + "license": "ISC" + }, + "node_modules/fx-runner/node_modules/which": { + "version": "1.2.4", + "dev": true, + "license": "ISC", + "dependencies": { + "is-absolute": "^0.1.7", + "isexe": "^1.1.1" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/global-directory": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/graceful-readlink": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/growly": { + "version": "1.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-absolute": { + "version": "0.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "is-relative": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-installed-globally": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-installed-globally/node_modules/is-path-inside": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-npm": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-relative": { + "version": "0.1.3", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-utf8": { + "version": "0.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "5.9.6", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-merge-patch": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/ky": { + "version": "1.14.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, + "node_modules/latest-version": { + "version": "9.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "package-json": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lighthouse-logger": { + "version": "2.0.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.1", + "marky": "^1.2.2" + } + }, + "node_modules/lines-and-columns": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "dev": true, + "license": "ISC" + }, + "node_modules/marky": { + "version": "1.3.0", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/multimatch": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "^3.0.5", + "array-differ": "^4.0.0", + "array-union": "^3.0.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/node-forge": { + "version": "1.4.0", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-notifier": { + "version": "10.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "growly": "^1.3.0", + "is-wsl": "^2.2.0", + "semver": "^7.3.5", + "shellwords": "^0.1.1", + "uuid": "^8.3.2", + "which": "^2.0.2" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/os-shim": { + "version": "0.1.3", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json": { + "version": "10.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ky": "^1.2.0", + "registry-auth-token": "^5.0.2", + "registry-url": "^6.0.1", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.21.4", + "error-ex": "^1.3.2", + "json-parse-even-better-errors": "^3.0.0", + "lines-and-columns": "^2.0.3", + "type-fest": "^3.8.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "3.13.1", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/pino": { + "version": "9.9.5", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.28", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/promise-toolbox": { + "version": "0.21.0", + "dev": true, + "license": "ISC", + "dependencies": { + "make-error": "^1.3.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pupa": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "dev": true, + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "dev": true, + "license": "ISC" + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/registry-auth-token": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^3.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/registry-url": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.7.3", + "dev": true, + "license": "MIT" + }, + "node_modules/shellwords": { + "version": "0.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spawn-sync": { + "version": "1.0.15", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "concat-stream": "^1.4.7", + "os-shim": "^0.1.2" + } + }, + "node_modules/split": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-bom-buf": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-utf8": "^0.2.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom-stream": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "first-chunk-stream": "^3.0.0", + "strip-bom-buf": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/through": { + "version": "2.3.8", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-notifier": { + "version": "7.3.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boxen": "^8.0.1", + "chalk": "^5.3.0", + "configstore": "^7.0.0", + "is-in-ci": "^1.0.0", + "is-installed-globally": "^1.0.0", + "is-npm": "^6.0.0", + "latest-version": "^9.0.0", + "pupa": "^3.1.0", + "semver": "^7.6.3", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/watchpack": { + "version": "2.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-ext": { + "version": "8.10.0", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@babel/runtime": "7.28.4", + "@devicefarmer/adbkit": "3.3.8", + "addons-linter": "7.20.0", + "camelcase": "8.0.0", + "chrome-launcher": "1.2.0", + "debounce": "1.2.1", + "decamelize": "6.0.1", + "es6-error": "4.1.1", + "firefox-profile": "4.7.0", + "fx-runner": "1.4.0", + "https-proxy-agent": "^7.0.0", + "jose": "5.9.6", + "jszip": "3.10.1", + "multimatch": "6.0.0", + "node-notifier": "10.0.1", + "open": "10.2.0", + "parse-json": "7.1.1", + "pino": "9.9.5", + "promise-toolbox": "0.21.0", + "source-map-support": "0.5.21", + "strip-bom": "5.0.0", + "strip-json-comments": "5.0.3", + "tmp": "0.2.5", + "update-notifier": "7.3.1", + "watchpack": "2.4.4", + "yargs": "17.7.2", + "zip-dir": "2.0.0" + }, + "bin": { + "web-ext": "bin/web-ext.js" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + } + }, + "node_modules/when": { + "version": "3.7.7", + "dev": true, + "license": "MIT" + }, + "node_modules/when-exit": { + "version": "2.1.5", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/winreg": { + "version": "0.0.12", + "dev": true, + "license": "BSD" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/is-wsl": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-dir": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.0", + "jszip": "^3.2.2" + } + } + } +} diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 0000000..fc0024c --- /dev/null +++ b/extension/package.json @@ -0,0 +1,27 @@ +{ + "name": "@velox/extension", + "version": "0.0.0", + "private": true, + "description": "Velox Firefox extension (lane EXT). Collects URL + headers + cookies and hands downloads to veloxd; renders daemon state. Zero download logic.", + "type": "module", + "scripts": { + "build": "node scripts/build.mjs", + "prepare": "node scripts/build.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "lint": "web-ext lint --source-dir ." + }, + "devDependencies": { + "@types/firefox-webext-browser": "^120.0.4", + "@types/ws": "^8.5.12", + "esbuild": "^0.24.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0", + "web-ext": "^8.3.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/extension/scripts/build.mjs b/extension/scripts/build.mjs new file mode 100644 index 0000000..03c06b3 --- /dev/null +++ b/extension/scripts/build.mjs @@ -0,0 +1,46 @@ +// Bundles the extension's TypeScript entry points into dist/ for Firefox to load. +// +// Runs on `npm run build` and, via the `prepare` script, on every `npm ci` — so CI's +// `web-ext lint` (which needs the referenced bundles to exist) works without a separate +// build step. Firefox-only: no polyfill, native ESM, `browser.*` is a global. + +import { build } from 'esbuild'; +import { rm, mkdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const outdir = resolve(root, 'dist'); + +// One entry per manifest surface. Add popup/options/content here as they land. +const entryPoints = { + background: resolve(root, 'src/background/index.ts'), +}; + +await rm(outdir, { recursive: true, force: true }); +await mkdir(outdir, { recursive: true }); + +const watch = process.argv.includes('--watch'); +const dev = watch || process.argv.includes('--dev'); + +const options = { + entryPoints, + outdir, + bundle: true, + format: 'esm', + target: ['firefox128'], + platform: 'browser', + sourcemap: dev ? 'inline' : 'linked', + minify: !dev, + logLevel: 'info', + // A bare `import ... from 'ws'` etc. must never reach a bundle — fail loud if one does. + external: [], +}; + +if (watch) { + const ctx = await (await import('esbuild')).context(options); + await ctx.watch(); + console.log('esbuild: watching'); +} else { + await build(options); +} diff --git a/extension/src/background/capture/headers.ts b/extension/src/background/capture/headers.ts new file mode 100644 index 0000000..9d57636 --- /dev/null +++ b/extension/src/background/capture/headers.ts @@ -0,0 +1,205 @@ +// Request-header stash. +// +// `onHeadersReceived` (where shouldCapture runs) does not carry the request headers the +// browser actually sent — signed-URL and referrer-gated CDNs need those. So we stash them +// from `onBeforeSendHeaders`, keyed by requestId, and the capture hook reads them back a +// few milliseconds later. +// +// This map sees every request the browser makes. An unbounded or un-expired one is a +// memory leak that grows for the life of the session, so it is BOTH size-capped (oldest +// out first) AND time-capped (5-minute TTL), and it is cleared for a request as soon as +// that request finishes or errors. + +import type { Headers } from '../../shared/protocol/index.js'; + +export interface StashedRequest { + requestId: string; + url: string; + method: string; + /** Lower-cased header names; multiple values for one name joined with ", ". */ + headers: Headers; + tabId: number; + stashedAt: number; +} + +interface HeaderEntry { + name: string; + value?: string; +} + +export interface OnBeforeSendHeadersDetails { + requestId: string; + url: string; + method: string; + tabId: number; + requestHeaders?: HeaderEntry[]; +} + +interface RequestEndDetails { + requestId: string; +} + +interface WebRequestEvent void> { + addListener(listener: L, filter: { urls: string[] }, extraInfoSpec?: string[]): void; + removeListener(listener: L): void; +} + +/** The slice of `browser.webRequest` this stash wires itself to. */ +export interface WebRequestLike { + onBeforeSendHeaders: WebRequestEvent<(d: OnBeforeSendHeadersDetails) => void>; + onCompleted: WebRequestEvent<(d: RequestEndDetails) => void>; + onErrorOccurred: WebRequestEvent<(d: RequestEndDetails) => void>; +} + +export interface HeaderStashOptions { + /** Hard ceiling on live entries; the oldest is evicted past this. Default 2048. */ + maxEntries?: number; + /** Entries older than this are treated as absent and swept. Default 5 min. */ + ttlMs?: number; + /** Background sweep cadence. 0 disables the timer (callers can sweep() by hand). Default 60 s. */ + sweepIntervalMs?: number; + now?: () => number; +} + +const ALL_URLS = ''; + +export class HeaderStash { + private readonly maxEntries: number; + private readonly ttlMs: number; + private readonly sweepIntervalMs: number; + private readonly now: () => number; + + /** Insertion-ordered, so the first key is always the oldest. */ + private readonly entries = new Map(); + private sweepTimer: ReturnType | null = null; + + private boundBeforeSend?: (d: OnBeforeSendHeadersDetails) => void; + private boundEnd?: (d: RequestEndDetails) => void; + private attachedTo?: WebRequestLike; + + constructor(opts: HeaderStashOptions = {}) { + this.maxEntries = opts.maxEntries ?? 2048; + this.ttlMs = opts.ttlMs ?? 5 * 60_000; + this.sweepIntervalMs = opts.sweepIntervalMs ?? 60_000; + this.now = opts.now ?? Date.now; + } + + get size(): number { + return this.entries.size; + } + + put(details: OnBeforeSendHeadersDetails): void { + const record: StashedRequest = { + requestId: details.requestId, + url: details.url, + method: details.method, + headers: normalizeHeaders(details.requestHeaders), + tabId: details.tabId, + stashedAt: this.now(), + }; + // Re-insert so a redirected request (same id, fresh onBeforeSendHeaders) moves to the + // newest slot and its TTL restarts. + this.entries.delete(record.requestId); + this.entries.set(record.requestId, record); + + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + } + + /** Read and remove — the normal path once a request has been offered to the daemon. */ + take(requestId: string): StashedRequest | undefined { + const found = this.peek(requestId); + if (found) this.entries.delete(requestId); + return found; + } + + /** Read without removing. Returns undefined for an unknown or expired entry (which it + * also deletes). */ + peek(requestId: string): StashedRequest | undefined { + const record = this.entries.get(requestId); + if (!record) return undefined; + if (this.now() - record.stashedAt > this.ttlMs) { + this.entries.delete(requestId); + return undefined; + } + return record; + } + + drop(requestId: string): void { + this.entries.delete(requestId); + } + + /** Remove every expired entry. Returns how many went. */ + sweep(): number { + const cutoff = this.now() - this.ttlMs; + let removed = 0; + for (const [id, record] of this.entries) { + if (record.stashedAt <= cutoff) { + this.entries.delete(id); + removed += 1; + } else { + // insertion order == age order, so the first survivor ends the sweep + break; + } + } + return removed; + } + + clear(): void { + this.entries.clear(); + } + + /** Wire onto `browser.webRequest`: stash on send, forget on finish/error, sweep on a timer. */ + attach(webRequest: WebRequestLike): void { + if (this.attachedTo) throw new Error('HeaderStash is already attached'); + this.attachedTo = webRequest; + + this.boundBeforeSend = (d) => this.put(d); + this.boundEnd = (d) => this.drop(d.requestId); + + webRequest.onBeforeSendHeaders.addListener(this.boundBeforeSend, { urls: [ALL_URLS] }, [ + 'requestHeaders', + ]); + webRequest.onCompleted.addListener(this.boundEnd, { urls: [ALL_URLS] }); + webRequest.onErrorOccurred.addListener(this.boundEnd, { urls: [ALL_URLS] }); + + if (this.sweepIntervalMs > 0) { + this.sweepTimer = setInterval(() => this.sweep(), this.sweepIntervalMs); + (this.sweepTimer as { unref?: () => void }).unref?.(); + } + } + + detach(): void { + const webRequest = this.attachedTo; + if (webRequest) { + if (this.boundBeforeSend) webRequest.onBeforeSendHeaders.removeListener(this.boundBeforeSend); + if (this.boundEnd) { + webRequest.onCompleted.removeListener(this.boundEnd); + webRequest.onErrorOccurred.removeListener(this.boundEnd); + } + } + this.attachedTo = undefined; + this.boundBeforeSend = undefined; + this.boundEnd = undefined; + if (this.sweepTimer) { + clearInterval(this.sweepTimer); + this.sweepTimer = null; + } + } +} + +/** Array<{name,value}> → { "lower-name": "value[, value]" }. */ +export function normalizeHeaders(list: HeaderEntry[] | undefined): Headers { + const out: Headers = {}; + if (!list) return out; + for (const { name, value } of list) { + if (value === undefined) continue; + const key = name.toLowerCase(); + const existing = out[key]; + out[key] = existing === undefined ? value : `${existing}, ${value}`; + } + return out; +} diff --git a/extension/src/background/capture/rules.ts b/extension/src/background/capture/rules.ts new file mode 100644 index 0000000..339e091 --- /dev/null +++ b/extension/src/background/capture/rules.ts @@ -0,0 +1,195 @@ +// shouldCapture() — the one decision that makes or breaks a header-hook download manager. +// +// docs/05 §2: capture when ANY positive signal holds and NONE of the vetoes do. The +// vetoes are checked first and absolutely: a monitored .zip on an excluded host is not +// captured. Decisions are a pure function of the candidate plus the daemon's rules +// (mirrored via capture.getRules, so this never drifts from the daemon's policy). +// +// The companion decision table in tests/capture/rules.test.ts is the spec; it was written +// before this file and every branch here exists to satisfy a row in it. + +import type { CaptureRules, Headers } from '../../shared/protocol/index.js'; + +export type ResourceType = + | 'main_frame' + | 'sub_frame' + | 'stylesheet' + | 'script' + | 'image' + | 'font' + | 'object' + | 'xmlhttprequest' + | 'ping' + | 'csp_report' + | 'media' + | 'websocket' + | 'other' + | (string & {}); + +export interface CaptureCandidate { + /** The effective request URL (after redirects). */ + url: string; + method: string; + statusCode: number; + type: ResourceType; + /** Response headers, lower-cased names (see capture/headers.ts normalizeHeaders). */ + responseHeaders: Headers; + /** The request headers the browser sent, from the stash. Lower-cased names. */ + requestHeaders: Headers; + /** The document that initiated the request (originUrl / documentUrl / tab URL). */ + documentUrl?: string | null; + /** Whether the user held the bypass modifier on the click that started this. */ + bypassHeld?: boolean; +} + +export type CaptureReason = + | 'capture_disabled' + | 'not_a_get' + | 'bad_status' + | 'bypass_modifier' + | 'blob_or_data_origin' + | 'excluded_host' + | 'page_range_request' + | 'streaming_media' + | 'html_navigation' + | 'content_disposition' + | 'monitored_extension' + | 'monitored_mime' + | 'over_min_size' + | 'no_rule_matched'; + +export interface CaptureDecision { + capture: boolean; + reason: CaptureReason; +} + +const RENDERABLE_TYPES = new Set([ + 'text/html', + 'application/xhtml+xml', + 'text/plain', + 'text/xml', + 'application/xml', + 'application/json', + 'application/pdf', +]); + +const STREAMING_MANIFEST_TYPES = new Set([ + 'application/vnd.apple.mpegurl', + 'application/x-mpegurl', + 'audio/mpegurl', + 'audio/x-mpegurl', + 'application/dash+xml', +]); + +export function shouldCapture(c: CaptureCandidate, rules: CaptureRules): CaptureDecision { + const no = (reason: CaptureReason): CaptureDecision => ({ capture: false, reason }); + const yes = (reason: CaptureReason): CaptureDecision => ({ capture: true, reason }); + + // --- vetoes, in priority order -------------------------------------------------- + if (!rules.enabled) return no('capture_disabled'); + if (c.method.toUpperCase() !== 'GET') return no('not_a_get'); + if (c.statusCode !== 200 && c.statusCode !== 206) return no('bad_status'); + if (c.bypassHeld) return no('bypass_modifier'); + + if (isBlobOrData(c.documentUrl) || isBlobOrData(c.url)) return no('blob_or_data_origin'); + + const host = hostOf(c.url); + if (host && hostExcluded(host, rules.excludedHosts)) return no('excluded_host'); + + // A Range the page put on the request itself (a media player, pdf.js): the daemon, + // not us, does ranged fetches — so this is playback/rendering, not a download. + if (c.requestHeaders['range'] !== undefined) return no('page_range_request'); + + const contentType = mimeOf(c.responseHeaders); + if (c.type === 'media' || isStreamingManifest(contentType, c.url)) return no('streaming_media'); + + const attachment = dispositionIsAttachment(c.responseHeaders['content-disposition']); + + if (isNavigation(c.type) && isHtml(contentType) && !attachment) return no('html_navigation'); + + // --- positives --------------------------------------------------------------------- + if (attachment) return yes('content_disposition'); + + const ext = filenameExtension(c.url); + if (ext && rules.monitoredExtensions.includes(ext)) return yes('monitored_extension'); + + if (contentType && contentType !== 'text/html' && rules.monitoredMimeTypes.includes(contentType)) { + return yes('monitored_mime'); + } + + const length = Number(c.responseHeaders['content-length']); + if (Number.isFinite(length) && length > rules.minSizeBytes && !isRenderable(contentType)) { + return yes('over_min_size'); + } + + return no('no_rule_matched'); +} + +// --- helpers ---------------------------------------------------------------------- + +function isBlobOrData(url: string | null | undefined): boolean { + return typeof url === 'string' && /^(blob:|data:)/i.test(url); +} + +function hostOf(url: string): string { + try { + return new URL(url).hostname; + } catch { + return ''; + } +} + +/** `example.com` matches that host exactly; `*.example.com` matches any subdomain of it. */ +export function hostExcluded(host: string, patterns: readonly string[]): boolean { + for (const p of patterns) { + if (p.startsWith('*.')) { + if (host === p.slice(2) || host.endsWith(p.slice(1))) return true; + } else if (host === p) { + return true; + } + } + return false; +} + +function mimeOf(headers: Headers): string { + const raw = headers['content-type']; + if (!raw) return ''; + return raw.split(';', 1)[0]!.trim().toLowerCase(); +} + +function isNavigation(type: ResourceType): boolean { + return type === 'main_frame' || type === 'sub_frame'; +} + +function isHtml(mime: string): boolean { + return mime === 'text/html' || mime === 'application/xhtml+xml'; +} + +function isRenderable(mime: string): boolean { + return RENDERABLE_TYPES.has(mime) || mime.startsWith('image/'); +} + +function isStreamingManifest(mime: string, url: string): boolean { + if (STREAMING_MANIFEST_TYPES.has(mime)) return true; + const path = pathOf(url).toLowerCase(); + return path.endsWith('.m3u8') || path.endsWith('.mpd'); +} + +function dispositionIsAttachment(value: string | undefined): boolean { + return value !== undefined && /^\s*attachment\s*(;|$)/i.test(value); +} + +function pathOf(url: string): string { + try { + return new URL(url).pathname; + } catch { + return url.split(/[?#]/, 1)[0]!; + } +} + +export function filenameExtension(url: string): string { + const path = pathOf(url); + const base = path.slice(path.lastIndexOf('/') + 1); + const dot = base.lastIndexOf('.'); + return dot > 0 ? base.slice(dot + 1).toLowerCase() : ''; +} diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts new file mode 100644 index 0000000..eb846c0 --- /dev/null +++ b/extension/src/background/index.ts @@ -0,0 +1,26 @@ +// Background event-page entry. +// +// Brings the transport up and holds the single shared reference to it. Capture, the +// popup relay, and context menus attach here as they land (docs/05 §6 build order). + +import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js'; + +let transport: VeloxTransport | undefined; + +function onTransportState(status: TransportStatus): void { + const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : ''); + console.debug(`[velox] transport ${status.state}${detail ? ` — ${detail}` : ''}`); +} + +async function start(): Promise { + transport = await createTransport(); + transport.onStateChange(onTransportState); + onTransportState(transport.status); +} + +void start(); + +/** Exposed for the surfaces wired in later steps of the build order. */ +export function getTransport(): VeloxTransport | undefined { + return transport; +} diff --git a/extension/src/background/transport/.gitkeep b/extension/src/background/transport/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/extension/src/background/transport/backoff.ts b/extension/src/background/transport/backoff.ts new file mode 100644 index 0000000..9f54334 --- /dev/null +++ b/extension/src/background/transport/backoff.ts @@ -0,0 +1,46 @@ +// Exponential backoff with full jitter, for reconnect scheduling. +// +// Kept tiny and separate so a test can assert the sequence without faking sockets. + +export interface BackoffOptions { + baseMs?: number; + factor?: number; + maxMs?: number; + /** 0 = no jitter (deterministic, for tests); 1 = full jitter. Default 0.2. */ + jitter?: number; + random?: () => number; +} + +export class Backoff { + private readonly baseMs: number; + private readonly factor: number; + private readonly maxMs: number; + private readonly jitter: number; + private readonly random: () => number; + private attempt = 0; + + constructor(opts: BackoffOptions = {}) { + this.baseMs = opts.baseMs ?? 500; + this.factor = opts.factor ?? 2; + this.maxMs = opts.maxMs ?? 30_000; + this.jitter = opts.jitter ?? 0.2; + this.random = opts.random ?? Math.random; + } + + get attempts(): number { + return this.attempt; + } + + /** The delay for the next retry, and advances the counter. */ + next(): number { + const raw = Math.min(this.maxMs, this.baseMs * this.factor ** this.attempt); + this.attempt += 1; + if (this.jitter <= 0) return Math.round(raw); + const spread = raw * this.jitter; + return Math.round(raw - spread + this.random() * spread * 2); + } + + reset(): void { + this.attempt = 0; + } +} diff --git a/extension/src/background/transport/discovery.ts b/extension/src/background/transport/discovery.ts new file mode 100644 index 0000000..0d1f86e --- /dev/null +++ b/extension/src/background/transport/discovery.ts @@ -0,0 +1,30 @@ +// Port discovery for the WebSocket transport. +// +// The extension cannot read $XDG_RUNTIME_DIR/velox/ws.port, so veloxd binds the first +// free port in a small fixed range and the extension probes them (docs/05 §4.B). This +// module only decides the order to try; the handshake that confirms a port lives in +// websocket.ts so there is exactly one copy of it. + +export interface PortRange { + readonly start: number; + readonly end: number; +} + +/** 52000–52016 inclusive — 17 ports, matching docs/05 §4.B. */ +export const WS_PORT_RANGE: PortRange = { start: 52000, end: 52016 }; + +/** + * The full range, in the order to try it: the last-known-good port first (when it is + * in range), then the rest ascending. A stable order keeps a second daemon on a higher + * port from being picked while the real one is still on its usual port. + */ +export function candidatePorts(range: PortRange = WS_PORT_RANGE, preferred?: number | null): number[] { + const ports: number[] = []; + if (typeof preferred === 'number' && preferred >= range.start && preferred <= range.end) { + ports.push(preferred); + } + for (let p = range.start; p <= range.end; p += 1) { + if (p !== preferred) ports.push(p); + } + return ports; +} diff --git a/extension/src/background/transport/index.ts b/extension/src/background/transport/index.ts new file mode 100644 index 0000000..b7d29a9 --- /dev/null +++ b/extension/src/background/transport/index.ts @@ -0,0 +1,98 @@ +// Runtime transport selection. +// +// docs/adr/0003: WebSocket is the guaranteed path; native messaging is an opportunistic +// upgrade that is only taken when its handshake actually succeeds, never on detection +// alone. The user can force one from Options (the override, persisted in storage). + +import type { BackoffOptions } from './backoff.js'; +import { WS_PORT_RANGE, type PortRange } from './discovery.js'; +import { NativeTransport, type ConnectNative } from './native.js'; +import * as storage from './storage.js'; +import type { TransportOverride } from './storage.js'; +import type { VeloxTransport } from './types.js'; +import { WebSocketTransport, type WebSocketCtor, type WebSocketTransportDeps } from './websocket.js'; + +export type { + VeloxTransport, + TransportStatus, + TransportState, + TransportKind, + CallOptions, +} from './types.js'; +export { + RpcError, + RpcTimeoutError, + TransportClosedError, + MethodNotAllowedError, +} from './types.js'; +export { WebSocketTransport } from './websocket.js'; +export { NativeTransport, HOST_NAME } from './native.js'; +export { WS_PORT_RANGE, candidatePorts } from './discovery.js'; +export * as transportStorage from './storage.js'; + +export interface CreateTransportOptions { + /** Defaults to the persisted Options value. */ + override?: TransportOverride; + portRange?: PortRange; + backoff?: BackoffOptions; + autoPair?: boolean; + /** Test seams. */ + webSocketCtor?: WebSocketCtor; + connectNative?: ConnectNative; +} + +function wsDeps(opts: CreateTransportOptions): WebSocketTransportDeps { + const deps: WebSocketTransportDeps = { + getToken: storage.getToken, + setToken: storage.setToken, + getCachedPort: storage.getCachedWsPort, + setCachedPort: storage.setCachedWsPort, + extensionId: storage.extensionOriginId(), + portRange: opts.portRange ?? WS_PORT_RANGE, + }; + if (opts.webSocketCtor) deps.webSocketCtor = opts.webSocketCtor; + if (opts.backoff) deps.backoff = opts.backoff; + if (opts.autoPair !== undefined) deps.autoPair = opts.autoPair; + return deps; +} + +function nativeDeps(opts: CreateTransportOptions): ConstructorParameters[0] { + const connectNative = + opts.connectNative ?? (browser.runtime.connectNative.bind(browser.runtime) as unknown as ConnectNative); + return opts.backoff ? { connectNative, backoff: opts.backoff } : { connectNative }; +} + +/** + * Build a transport and bring it up. The returned transport keeps itself connected + * afterwards (reconnect with backoff). On the recoverable failure — veloxd simply not + * running yet — this still resolves with a live WebSocket transport whose status reads + * 'disconnected' and which is already retrying; callers render that as the red dot. + * It rejects only when the user explicitly forced native messaging and that failed. + */ +export async function createTransport(opts: CreateTransportOptions = {}): Promise { + const override = opts.override ?? (await storage.getOverride()); + + if (override === 'uds') { + const t = new NativeTransport(nativeDeps(opts)); + await t.connect(); // explicit choice — surface the failure + return t; + } + + if (override === 'ws') { + const t = new WebSocketTransport(wsDeps(opts)); + await t.connect().catch(() => undefined); + return t; + } + + // auto: try native, fall back to WebSocket. + const native = new NativeTransport(nativeDeps(opts)); + try { + await native.connect(); + return native; + } catch { + native.disconnect(); + } + const ws = new WebSocketTransport(wsDeps(opts)); + await ws.connect().catch(() => undefined); + return ws; +} diff --git a/extension/src/background/transport/native.ts b/extension/src/background/transport/native.ts new file mode 100644 index 0000000..c154d7e --- /dev/null +++ b/extension/src/background/transport/native.ts @@ -0,0 +1,333 @@ +// NativeTransport — the opportunistic upgrade (docs/adr/0003). +// +// browser.runtime.connectNative("com.velox.host") → velox-nmhost → veloxd's Unix socket +// +// No pairing and no token: the Unix socket authorizes by SO_PEERCRED (contract note on +// SessionHelloParams.token). On snap Firefox this only works when the manifest is in the +// real ~/.mozilla/native-messaging-hosts/ (ADR 0003); when the host is not installed the +// connect rejects fast so the runtime picker can fall through to WebSocket. +// +// State/backoff plumbing parallels WebSocketTransport; it is kept separate because the +// connect paths (port scan + pairing vs. a single connectNative) share little. + +import { ErrorCode, METHODS, PROTOCOL_VERSION, isAllowedOn } from '../../shared/protocol/index.js'; +import type { + MethodName, + Params, + Result, + SessionHelloParams, + SessionHelloResult, +} from '../../shared/protocol/index.js'; + +import { Backoff, type BackoffOptions } from './backoff.js'; +import { RpcConnection } from './rpc.js'; +import { + MethodNotAllowedError, + RpcError, + TransportClosedError, + type CallOptions, + type EventListener, + type TransportState, + type TransportStatus, + type VeloxTransport, +} from './types.js'; + +export const HOST_NAME = 'com.velox.host'; +const CLIENT_NAME = 'Firefox (Velox extension)'; + +export interface NativePort { + postMessage(message: unknown): void; + disconnect(): void; + onMessage: { + addListener(cb: (message: unknown) => void): void; + removeListener(cb: (message: unknown) => void): void; + }; + onDisconnect: { + addListener(cb: (port?: unknown) => void): void; + removeListener(cb: (port?: unknown) => void): void; + }; + error?: { message: string } | null; +} +export type ConnectNative = (application: string) => NativePort; + +const NOT_INSTALLED = /no such native application|not found|no such file|failed to (start|connect|execute)/i; + +export interface NativeTransportDeps { + connectNative: ConnectNative; + hostName?: string; + clientName?: string; + backoff?: BackoffOptions; + helloTimeoutMs?: number; +} + +export class NativeTransport implements VeloxTransport { + readonly kind = 'uds' as const; + + private readonly connectNative: ConnectNative; + private readonly hostName: string; + private readonly clientName: string; + private readonly helloTimeoutMs: number; + private readonly backoff: Backoff; + + private port: NativePort | null = null; + private rpc: RpcConnection | null = null; + private portDead = false; + + private stopped = false; + private connectPromise: Promise | null = null; + private reconnectTimer: ReturnType | null = null; + + private readonly listeners = new Map>(); + private readonly stateListeners = new Set<(status: TransportStatus) => void>(); + + private _state: TransportState = 'disconnected'; + private readonly _status: TransportStatus = { + state: 'disconnected', + needsPairing: false, + fatal: null, + retryAfterSec: null, + sessionId: null, + daemonVersion: null, + capabilities: [], + }; + + constructor(deps: NativeTransportDeps) { + this.connectNative = deps.connectNative; + this.hostName = deps.hostName ?? HOST_NAME; + this.clientName = deps.clientName ?? CLIENT_NAME; + this.helloTimeoutMs = deps.helloTimeoutMs ?? METHODS['session.hello'].deadlineMs; + this.backoff = new Backoff(deps.backoff); + } + + get state(): TransportState { + return this._state; + } + + get status(): TransportStatus { + return { ...this._status, capabilities: [...this._status.capabilities] }; + } + + connect(): Promise { + if (this._state === 'connected') return Promise.resolve(); + if (this.connectPromise) return this.connectPromise; + this.stopped = false; + this.connectPromise = this.openOnce().finally(() => { + this.connectPromise = null; + }); + return this.connectPromise; + } + + disconnect(): void { + this.stopped = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.teardownPort(); + this.setState('disconnected'); + } + + call(method: M, params: Params, opts?: CallOptions): Promise> { + if (!isAllowedOn(method, 'uds')) { + return Promise.reject(new MethodNotAllowedError(method, 'uds')); + } + const rpc = this.rpc; + if (!rpc || this._state !== 'connected') { + return Promise.reject(new TransportClosedError()); + } + const timeoutMs = opts?.timeoutMs ?? METHODS[method].deadlineMs; + return rpc.request(method, params, timeoutMs, opts?.signal) as Promise>; + } + + on(event: string, cb: EventListener): void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(cb); + } + + off(event: string, cb: EventListener): void { + this.listeners.get(event)?.delete(cb); + } + + onStateChange(cb: (status: TransportStatus) => void): () => void { + this.stateListeners.add(cb); + return () => this.stateListeners.delete(cb); + } + + // --- connect --------------------------------------------------------------------- + + private async openOnce(): Promise { + this.setState('connecting'); + + let port: NativePort; + try { + port = this.connectNative(this.hostName); + } catch (err) { + this._status.fatal = `native messaging unavailable: ${String(err)}`; + this.setState('disconnected'); + throw new RpcError(ErrorCode.InternalError, this._status.fatal); + } + + const rpc = new RpcConnection((frame) => port.postMessage(frame)); + const onMessage = (message: unknown): void => { + const note = rpc.handleInbound(message as Record); + if (note) this.dispatchEvent(note.method, note.params); + }; + port.onMessage.addListener(onMessage); + + // A missing host surfaces as an immediate onDisconnect, not a throw. + const drop = { hit: false, error: '' }; + const onDisconnect = (): void => { + drop.hit = true; + drop.error = port.error?.message ?? 'native port disconnected'; + // Fail the pending hello immediately instead of waiting out its deadline. + rpc.failAll(new TransportClosedError('native port disconnected during handshake')); + }; + port.onDisconnect.addListener(onDisconnect); + + let hello: SessionHelloResult; + try { + hello = await this.hello(rpc); + } catch (err) { + port.onMessage.removeListener(onMessage); + port.onDisconnect.removeListener(onDisconnect); + safeDisconnect(port); + + if (drop.hit) { + if (NOT_INSTALLED.test(drop.error)) { + this._status.fatal = 'native messaging host is not installed'; + this.setState('disconnected'); + throw new RpcError(ErrorCode.InternalError, this._status.fatal); + } + // Host is installed but died: recoverable. + this.setState('disconnected'); + this.scheduleReconnect(); + throw new TransportClosedError(`native host disconnected: ${drop.error}`); + } + if (err instanceof RpcError && err.code === ErrorCode.VersionMismatch) { + this._status.fatal = `protocol mismatch: ${err.message}`; + this.setState('disconnected'); + throw err; + } + this.setState('disconnected'); + this.scheduleReconnect(); + throw err instanceof Error ? err : new TransportClosedError(String(err)); + } + + // Handshake done — keep the disconnect listener, swap its job to reconnect. + port.onDisconnect.removeListener(onDisconnect); + this.adoptPort(port, rpc, onMessage); + this.backoff.reset(); + this.applyHello(hello); + this.setState('connected'); + } + + private async hello(rpc: RpcConnection): Promise { + const params: SessionHelloParams = { + clientType: 'extension', + clientName: this.clientName, + protocolVersion: PROTOCOL_VERSION, + token: null, + }; + return (await rpc.request('session.hello', params, this.helloTimeoutMs)) as SessionHelloResult; + } + + private adoptPort(port: NativePort, rpc: RpcConnection, onMessage: (m: unknown) => void): void { + this.port = port; + this.rpc = rpc; + this.portDead = false; + const onDrop = (): void => this.handleDrop(port, onMessage); + port.onDisconnect.addListener(onDrop); + } + + private handleDrop(port: NativePort, onMessage: (m: unknown) => void): void { + if (port !== this.port || this.portDead) return; + this.portDead = true; + port.onMessage.removeListener(onMessage); + this.rpc?.failAll(new TransportClosedError('native port disconnected')); + this.port = null; + this.rpc = null; + this.setState('disconnected'); + if (this.stopped || this._status.fatal || this._status.needsPairing) return; + this.scheduleReconnect(); + } + + private scheduleReconnect(): void { + if (this.stopped || this.reconnectTimer) return; + const delay = this.backoff.next(); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (this.stopped) return; + this.openOnce().catch(() => { + /* openOnce() latches status / schedules the next attempt itself */ + }); + }, delay); + (this.reconnectTimer as { unref?: () => void }).unref?.(); + } + + private teardownPort(): void { + const port = this.port; + this.port = null; + this.rpc?.failAll(new TransportClosedError('transport closed')); + this.rpc = null; + if (port) { + this.portDead = true; + safeDisconnect(port); + } + } + + private applyHello(hello: SessionHelloResult): void { + this._status.sessionId = hello.sessionId; + this._status.daemonVersion = hello.daemonVersion; + this._status.capabilities = hello.capabilities; + } + + private setState(state: TransportState): void { + if (state === 'connected') { + this._status.needsPairing = false; + this._status.fatal = null; + this._status.retryAfterSec = null; + } + if (state !== 'connected') { + this._status.sessionId = null; + this._status.daemonVersion = null; + this._status.capabilities = []; + } + const changed = this._state !== state; + this._state = state; + this._status.state = state; + if (changed || state === 'disconnected') { + const snapshot = this.status; + for (const cb of this.stateListeners) { + try { + cb(snapshot); + } catch (err) { + console.error('[velox] state listener threw', err); + } + } + } + } + + private dispatchEvent(event: string, payload: unknown): void { + const set = this.listeners.get(event); + if (!set) return; + for (const cb of set) { + try { + cb(payload); + } catch (err) { + console.error(`[velox] listener for ${event} threw`, err); + } + } + } +} + +function safeDisconnect(port: NativePort): void { + try { + port.disconnect(); + } catch { + /* already gone */ + } +} diff --git a/extension/src/background/transport/rpc.ts b/extension/src/background/transport/rpc.ts new file mode 100644 index 0000000..d8542fd --- /dev/null +++ b/extension/src/background/transport/rpc.ts @@ -0,0 +1,137 @@ +// JSON-RPC 2.0 request/response correlation, shared by both transports. +// +// It owns no socket. It builds requests, matches replies to them by id, enforces the +// per-call deadline, and hands notifications back to the caller to route. A transport +// embeds one and feeds it every inbound frame. + +import { RpcError, RpcTimeoutError } from './types.js'; + +export interface JsonRpcRequest { + jsonrpc: '2.0'; + id: number; + method: string; + params: unknown; +} + +export interface JsonRpcNotification { + jsonrpc: '2.0'; + method: string; + params: unknown; +} + +interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number | null; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +interface Pending { + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; + timer: ReturnType; + signal?: AbortSignal; + onAbort?: () => void; +} + +export class RpcConnection { + private seq = 0; + private readonly pending = new Map(); + + constructor(private readonly send: (frame: JsonRpcRequest) => void) {} + + get inFlight(): number { + return this.pending.size; + } + + request(method: string, params: unknown, timeoutMs: number, signal?: AbortSignal): Promise { + this.seq += 1; + const id = this.seq; + return new Promise((resolve, reject) => { + const finish = (): Pending | undefined => { + const p = this.pending.get(id); + if (p) { + this.pending.delete(id); + clearTimeout(p.timer); + if (p.signal && p.onAbort) p.signal.removeEventListener('abort', p.onAbort); + } + return p; + }; + + const timer = setTimeout(() => { + finish(); + reject(new RpcTimeoutError(method, timeoutMs)); + }, timeoutMs); + + const entry: Pending = { resolve, reject, timer }; + if (signal) { + if (signal.aborted) { + clearTimeout(timer); + reject(signal.reason ?? new DOMException('call aborted', 'AbortError')); + return; + } + entry.signal = signal; + entry.onAbort = () => { + finish(); + reject(signal.reason ?? new DOMException('call aborted', 'AbortError')); + }; + signal.addEventListener('abort', entry.onAbort, { once: true }); + } + this.pending.set(id, entry); + + try { + this.send({ jsonrpc: '2.0', id, method, params }); + } catch (err) { + finish(); + reject(err); + } + }); + } + + /** + * Feed one inbound frame. Returns the notification to route, or null when the frame + * was a response (already settled here), unparseable, or for an id we don't know. + */ + handleInbound(raw: string | Record): JsonRpcNotification | null { + let msg: unknown; + if (typeof raw === 'string') { + try { + msg = JSON.parse(raw); + } catch { + return null; + } + } else { + msg = raw; + } + if (typeof msg !== 'object' || msg === null) return null; + const frame = msg as JsonRpcResponse & JsonRpcNotification; + if (frame.jsonrpc !== '2.0') return null; + + if (frame.id === undefined || frame.id === null) { + return typeof frame.method === 'string' ? { jsonrpc: '2.0', method: frame.method, params: frame.params } : null; + } + + const p = this.pending.get(frame.id); + if (!p) return null; // unknown id: a late reply to a timed-out call, or not ours + this.pending.delete(frame.id); + clearTimeout(p.timer); + if (p.signal && p.onAbort) p.signal.removeEventListener('abort', p.onAbort); + + if (frame.error) { + p.reject(new RpcError(frame.error.code, frame.error.message, frame.error.data)); + } else { + p.resolve(frame.result); + } + return null; + } + + /** Reject every outstanding call. Used when the connection drops. */ + failAll(reason: unknown): void { + for (const p of this.pending.values()) { + clearTimeout(p.timer); + if (p.signal && p.onAbort) p.signal.removeEventListener('abort', p.onAbort); + p.reject(reason); + } + this.pending.clear(); + } +} diff --git a/extension/src/background/transport/storage.ts b/extension/src/background/transport/storage.ts new file mode 100644 index 0000000..c43aa96 --- /dev/null +++ b/extension/src/background/transport/storage.ts @@ -0,0 +1,57 @@ +// The few pieces of transport state that must survive a browser restart: +// the pairing token, the user's manual transport override, and a hint of which +// WebSocket port worked last time so the next scan starts there. +// +// Firefox exposes a promise-based `browser.*` natively, so there is no polyfill import. + +import type { TransportKind } from './types.js'; + +const KEY = { + token: 'velox.token', + override: 'velox.transportOverride', + wsPort: 'velox.wsPort', +} as const; + +/** 'auto' lets the runtime picker decide (see docs/adr/0003). */ +export type TransportOverride = 'auto' | TransportKind; + +async function get(key: string): Promise { + const bag = await browser.storage.local.get(key); + return bag[key] as T | undefined; +} + +export async function getToken(): Promise { + return (await get(KEY.token)) ?? null; +} + +export async function setToken(token: string | null): Promise { + if (token === null) await browser.storage.local.remove(KEY.token); + else await browser.storage.local.set({ [KEY.token]: token }); +} + +export async function getOverride(): Promise { + const v = await get(KEY.override); + return v === 'ws' || v === 'uds' ? v : 'auto'; +} + +export async function setOverride(value: TransportOverride): Promise { + await browser.storage.local.set({ [KEY.override]: value }); +} + +export async function getCachedWsPort(): Promise { + const v = await get(KEY.wsPort); + return typeof v === 'number' ? v : null; +} + +export async function setCachedWsPort(port: number | null): Promise { + if (port === null) await browser.storage.local.remove(KEY.wsPort); + else await browser.storage.local.set({ [KEY.wsPort]: port }); +} + +/** + * The moz-extension origin UUID — what session.pair wants as `extensionId` and what the + * daemon sees in the WS upgrade's Origin header. `getURL('/')` is `moz-extension:///`. + */ +export function extensionOriginId(): string { + return new URL(browser.runtime.getURL('/')).host; +} diff --git a/extension/src/background/transport/types.ts b/extension/src/background/transport/types.ts new file mode 100644 index 0000000..562af61 --- /dev/null +++ b/extension/src/background/transport/types.ts @@ -0,0 +1,111 @@ +// The transport boundary between the extension and veloxd. +// +// docs/05 §4 defines this interface and the reason there are two implementations; +// docs/adr/0003 settles which one is the default (WebSocket) and which is the +// opportunistic upgrade (native messaging) under snap Firefox. +// +// The generated protocol module exports a type it also calls `Transport` — the +// 'uds' | 'ws' wire discriminator. That is re-exported here as `TransportKind` so the +// two names never collide. + +import type { + MethodName, + Params, + Result, + Transport as TransportKind, +} from '../../shared/protocol/index.js'; +import type { EventName, EventPayload } from '../../shared/protocol/index.js'; + +export type { TransportKind }; + +export type TransportState = 'connected' | 'connecting' | 'disconnected'; + +export type EventListener = (payload: unknown) => void; + +export interface CallOptions { + /** Overrides the method's contract deadline (METHODS[method].deadlineMs). */ + timeoutMs?: number; + signal?: AbortSignal; +} + +/** Everything a consumer needs to render "is Velox reachable" without guessing. */ +export interface TransportStatus { + state: TransportState; + /** The transport has stopped retrying and needs the user to pair (or re-pair). */ + needsPairing: boolean; + /** Unrecoverable without a version/install change: protocol-major mismatch, or the + * native host is not installed. The transport does not retry while this is set. */ + fatal: string | null; + /** Seconds to wait before another pairing attempt makes sense (brute-force lockout). */ + retryAfterSec: number | null; + sessionId: string | null; + daemonVersion: string | null; + capabilities: readonly string[]; +} + +export interface VeloxTransport { + readonly kind: TransportKind; + readonly state: TransportState; + readonly status: TransportStatus; + + /** Resolves once a session.hello handshake has succeeded. Rejects if the first + * attempt cannot get there; after that the transport reconnects on its own. */ + connect(): Promise; + /** Stop for good: closes the socket and cancels any pending reconnect. */ + disconnect(): void; + + call(method: M, params: Params, opts?: CallOptions): Promise>; + + on(event: E, cb: (payload: EventPayload) => void): void; + on(event: string, cb: EventListener): void; + off(event: string, cb: EventListener): void; + + /** Fires on every state change. Returns an unsubscribe. */ + onStateChange(cb: (status: TransportStatus) => void): () => void; +} + +// --- errors --------------------------------------------------------------------------- + +/** A JSON-RPC error object came back for our call. `code` is a protocol ErrorCode. */ +export class RpcError extends Error { + constructor( + readonly code: number, + message: string, + readonly data?: unknown, + ) { + super(message); + this.name = 'RpcError'; + } +} + +/** The call's deadline elapsed with no reply. The socket is left alone; a late reply + * is dropped. The capture path treats this like any rejection: fail open. */ +export class RpcTimeoutError extends Error { + constructor( + readonly method: string, + readonly timeoutMs: number, + ) { + super(`${method} timed out after ${timeoutMs} ms`); + this.name = 'RpcTimeoutError'; + } +} + +/** The connection dropped (or was never up) while a call was outstanding. */ +export class TransportClosedError extends Error { + constructor(message = 'transport is not connected') { + super(message); + this.name = 'TransportClosedError'; + } +} + +/** The caller asked for a method the contract does not permit on this transport. + * Rejected locally so it fails in one place, not as a puzzling -32003 later. */ +export class MethodNotAllowedError extends Error { + constructor( + readonly method: string, + readonly transport: TransportKind, + ) { + super(`${method} is not permitted on the ${transport} transport`); + this.name = 'MethodNotAllowedError'; + } +} diff --git a/extension/src/background/transport/websocket.ts b/extension/src/background/transport/websocket.ts new file mode 100644 index 0000000..1676d3b --- /dev/null +++ b/extension/src/background/transport/websocket.ts @@ -0,0 +1,465 @@ +// WebSocketTransport — the primary path (docs/adr/0003). +// +// ws://127.0.0.1: port discovered by scanning 52000–52016 and verifying a +// session.hello handshake +// pairing session.pair once, token kept in browser.storage.local, +// sent on every later connect +// reconnect exponential backoff with jitter; stops only on a +// protocol-major mismatch or a refused/again-rate-limited pairing +// +// It holds no download logic and never inspects payloads beyond the JSON-RPC envelope. + +import { + ErrorCode, + METHODS, + PROTOCOL_VERSION, + isAllowedOn, +} from '../../shared/protocol/index.js'; +import type { + MethodName, + Params, + Result, + SessionHelloParams, + SessionHelloResult, + SessionPairParams, + SessionPairResult, +} from '../../shared/protocol/index.js'; + +import { Backoff, type BackoffOptions } from './backoff.js'; +import { candidatePorts, WS_PORT_RANGE, type PortRange } from './discovery.js'; +import { RpcConnection } from './rpc.js'; +import { + MethodNotAllowedError, + RpcError, + TransportClosedError, + type CallOptions, + type EventListener, + type TransportState, + type TransportStatus, + type VeloxTransport, +} from './types.js'; + +/** The slice of the WebSocket API this transport uses. Satisfied by the DOM `WebSocket` + * and by the `ws` package's client, so tests can run against a real loopback server. */ +export interface MinimalWebSocket { + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (ev: unknown) => void): void; + removeEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (ev: unknown) => void): void; +} +export type WebSocketCtor = new (url: string) => MinimalWebSocket; + +const CLIENT_NAME = 'Firefox (Velox extension)'; + +export interface WebSocketTransportDeps { + /** The pairing token, or null when unpaired. */ + getToken(): Promise; + setToken(token: string | null): Promise; + /** Last-known-good port to try first, or null. */ + getCachedPort(): Promise; + setCachedPort(port: number | null): Promise; + /** moz-extension origin UUID for session.pair. */ + extensionId: string; + + webSocketCtor?: WebSocketCtor; + portRange?: PortRange; + clientName?: string; + backoff?: BackoffOptions; + /** Pair automatically on first connect when there is no token. Options can disable + * this and call pair() itself. Default true. */ + autoPair?: boolean; + /** How long to wait for the socket to open before moving to the next port. */ + openTimeoutMs?: number; +} + +type PortOutcome = + | { kind: 'connected'; hello: SessionHelloResult } + | { kind: 'fatal'; reason: string; code: number } + | { kind: 'needs-pairing'; reason: string; code: number; retryAfterSec: number | null } + | { kind: 'retry'; reason: string }; // nothing velox-shaped answered here — try the next port + +type HelloOutcome = { ok: true; hello: SessionHelloResult } | { ok: false; error: unknown }; + +export class WebSocketTransport implements VeloxTransport { + readonly kind = 'ws' as const; + + private readonly ctor: WebSocketCtor; + private readonly portRange: PortRange; + private readonly clientName: string; + private readonly autoPair: boolean; + private readonly openTimeoutMs: number; + private readonly backoff: Backoff; + + private ws: MinimalWebSocket | null = null; + private rpc: RpcConnection | null = null; + private socketDropped = false; + + private stopped = false; + private connectPromise: Promise | null = null; + private reconnectTimer: ReturnType | null = null; + + private readonly listeners = new Map>(); + private readonly stateListeners = new Set<(status: TransportStatus) => void>(); + + private _state: TransportState = 'disconnected'; + private readonly _status: TransportStatus = { + state: 'disconnected', + needsPairing: false, + fatal: null, + retryAfterSec: null, + sessionId: null, + daemonVersion: null, + capabilities: [], + }; + + constructor(private readonly deps: WebSocketTransportDeps) { + this.ctor = deps.webSocketCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor); + this.portRange = deps.portRange ?? WS_PORT_RANGE; + this.clientName = deps.clientName ?? CLIENT_NAME; + this.autoPair = deps.autoPair ?? true; + this.openTimeoutMs = deps.openTimeoutMs ?? 2000; + this.backoff = new Backoff(deps.backoff); + } + + get state(): TransportState { + return this._state; + } + + get status(): TransportStatus { + return { ...this._status, capabilities: [...this._status.capabilities] }; + } + + connect(): Promise { + if (this._state === 'connected') return Promise.resolve(); + if (this.connectPromise) return this.connectPromise; + this.stopped = false; + this.connectPromise = this.openLoop().finally(() => { + this.connectPromise = null; + }); + return this.connectPromise; + } + + disconnect(): void { + this.stopped = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.teardownSocket(); + this.setState('disconnected'); + } + + call(method: M, params: Params, opts?: CallOptions): Promise> { + if (!isAllowedOn(method, 'ws')) { + return Promise.reject(new MethodNotAllowedError(method, 'ws')); + } + const rpc = this.rpc; + if (!rpc || this._state !== 'connected') { + return Promise.reject(new TransportClosedError()); + } + const timeoutMs = opts?.timeoutMs ?? METHODS[method].deadlineMs; + return rpc.request(method, params, timeoutMs, opts?.signal) as Promise>; + } + + on(event: string, cb: EventListener): void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(cb); + } + + off(event: string, cb: EventListener): void { + this.listeners.get(event)?.delete(cb); + } + + onStateChange(cb: (status: TransportStatus) => void): () => void { + this.stateListeners.add(cb); + return () => this.stateListeners.delete(cb); + } + + // --- connect loop ------------------------------------------------------------------ + + private async openLoop(): Promise { + this.setState('connecting'); + const preferred = await this.deps.getCachedPort(); + const ports = candidatePorts(this.portRange, preferred); + + let lastReason = 'no daemon found'; + for (const port of ports) { + if (this.stopped) throw new TransportClosedError('connect cancelled'); + const outcome = await this.tryPort(port); + switch (outcome.kind) { + case 'connected': + await this.deps.setCachedPort(port); + this.backoff.reset(); + this.applyHello(outcome.hello); + this.setState('connected'); + return; + case 'fatal': + this._status.fatal = outcome.reason; + this.setState('disconnected'); + throw new RpcError(outcome.code, outcome.reason); + case 'needs-pairing': + this._status.needsPairing = true; + this._status.retryAfterSec = outcome.retryAfterSec; + this.setState('disconnected'); + throw new RpcError(outcome.code, outcome.reason); + case 'retry': + lastReason = outcome.reason; + break; + } + } + + // Nothing answered. This is the recoverable case: veloxd may simply not be up yet. + this.setState('disconnected'); + this.scheduleReconnect(); + throw new TransportClosedError( + `no veloxd on ws://127.0.0.1:${this.portRange.start}-${this.portRange.end} (${lastReason})`, + ); + } + + private async tryPort(port: number): Promise { + let ws: MinimalWebSocket; + try { + ws = new this.ctor(`ws://127.0.0.1:${port}`); + } catch (err) { + return { kind: 'retry', reason: `construct failed: ${String(err)}` }; + } + + if (!(await this.awaitOpen(ws))) { + safeClose(ws); + return { kind: 'retry', reason: 'connection refused' }; + } + + const rpc = new RpcConnection((frame) => ws.send(JSON.stringify(frame))); + const onMessage = (ev: unknown): void => { + const data = (ev as { data?: unknown }).data; + const note = rpc.handleInbound(typeof data === 'string' ? data : String(data)); + if (note) this.dispatchEvent(note.method, note.params); + }; + ws.addEventListener('message', onMessage); + + // If the socket drops mid-handshake, fail the pending hello now rather than + // waiting out its deadline. + const onEarlyDrop = (): void => rpc.failAll(new TransportClosedError('socket closed during handshake')); + ws.addEventListener('close', onEarlyDrop); + ws.addEventListener('error', onEarlyDrop); + + const outcome = await this.runHandshake(rpc); + + ws.removeEventListener('close', onEarlyDrop); + ws.removeEventListener('error', onEarlyDrop); + + if (outcome.kind === 'connected') { + this.adoptSocket(ws, rpc, onMessage); + } else { + safeClose(ws); + } + return outcome; + } + + /** The hello (+ optional pair + re-hello) decision tree. Touches no socket state. */ + private async runHandshake(rpc: RpcConnection): Promise { + let hello = await this.hello(rpc); + if (hello.ok) return { kind: 'connected', hello: hello.hello }; + + if (hello.error instanceof RpcError && hello.error.code === ErrorCode.VersionMismatch) { + return { kind: 'fatal', reason: `protocol mismatch: ${hello.error.message}`, code: hello.error.code }; + } + if (!(hello.error instanceof RpcError) || hello.error.code !== ErrorCode.NotPaired) { + // Timed out, dropped, or an unexpected error to session.hello. + return { kind: 'retry', reason: describe(hello.error) }; + } + + // Unpaired. + if (!this.autoPair) { + return { kind: 'needs-pairing', reason: 'no pairing token', code: hello.error.code, retryAfterSec: null }; + } + const paired = await this.pair(rpc); + if (!paired.ok) { + const err = paired.error; + if (err instanceof RpcError && err.code === ErrorCode.RateLimited) { + const retry = (err.data as { retryAfterSec?: number } | undefined)?.retryAfterSec ?? null; + return { kind: 'needs-pairing', reason: 'pairing rate-limited', code: err.code, retryAfterSec: retry }; + } + const code = err instanceof RpcError ? err.code : ErrorCode.NotPaired; + const reason = err instanceof RpcError ? err.message : 'pairing failed'; + return { kind: 'needs-pairing', reason, code, retryAfterSec: null }; + } + await this.deps.setToken(paired.token); + + hello = await this.hello(rpc); + if (hello.ok) return { kind: 'connected', hello: hello.hello }; + if (hello.error instanceof RpcError && hello.error.code === ErrorCode.VersionMismatch) { + return { kind: 'fatal', reason: `protocol mismatch: ${hello.error.message}`, code: hello.error.code }; + } + return { kind: 'retry', reason: `hello after pair failed: ${describe(hello.error)}` }; + } + + private async hello(rpc: RpcConnection): Promise { + const token = await this.deps.getToken(); + const params: SessionHelloParams = { + clientType: 'extension', + clientName: this.clientName, + protocolVersion: PROTOCOL_VERSION, + token: token ?? null, + }; + try { + const hello = (await rpc.request( + 'session.hello', + params, + METHODS['session.hello'].deadlineMs, + )) as SessionHelloResult; + return { ok: true, hello }; + } catch (error) { + return { ok: false, error }; + } + } + + private async pair(rpc: RpcConnection): Promise<{ ok: true; token: string } | { ok: false; error: unknown }> { + const params: SessionPairParams = { + clientName: this.clientName, + extensionId: this.deps.extensionId, + }; + try { + const res = (await rpc.request( + 'session.pair', + params, + METHODS['session.pair'].deadlineMs, + )) as SessionPairResult; + return { ok: true, token: res.token }; + } catch (error) { + return { ok: false, error }; + } + } + + private awaitOpen(ws: MinimalWebSocket): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (value: boolean): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + ws.removeEventListener('open', onOpen); + ws.removeEventListener('error', onError); + ws.removeEventListener('close', onClose); + resolve(value); + }; + const onOpen = (): void => finish(true); + const onError = (): void => finish(false); + const onClose = (): void => finish(false); + const timer = setTimeout(() => finish(false), this.openTimeoutMs); + (timer as { unref?: () => void }).unref?.(); + ws.addEventListener('open', onOpen); + ws.addEventListener('error', onError); + ws.addEventListener('close', onClose); + }); + } + + private adoptSocket(ws: MinimalWebSocket, rpc: RpcConnection, onMessage: (ev: unknown) => void): void { + this.ws = ws; + this.rpc = rpc; + this.socketDropped = false; + const onDrop = (): void => this.handleDrop(ws); + ws.addEventListener('close', onDrop); + ws.addEventListener('error', onDrop); + // message listener stays attached from tryPort + void onMessage; + } + + private handleDrop(ws: MinimalWebSocket): void { + if (ws !== this.ws || this.socketDropped) return; + this.socketDropped = true; + this.rpc?.failAll(new TransportClosedError('connection dropped')); + this.ws = null; + this.rpc = null; + this.setState('disconnected'); + if (this.stopped || this._status.fatal || this._status.needsPairing) return; + this.scheduleReconnect(); + } + + private scheduleReconnect(): void { + if (this.stopped || this.reconnectTimer) return; + const delay = this.backoff.next(); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (this.stopped) return; + this.openLoop().catch(() => { + // openLoop() already schedules the next attempt on the recoverable path, + // and latches status on the terminal ones. Nothing to do here. + }); + }, delay); + (this.reconnectTimer as { unref?: () => void }).unref?.(); + } + + private teardownSocket(): void { + const ws = this.ws; + this.ws = null; + this.rpc?.failAll(new TransportClosedError('transport closed')); + this.rpc = null; + if (ws) { + this.socketDropped = true; + safeClose(ws); + } + } + + private applyHello(hello: SessionHelloResult): void { + this._status.sessionId = hello.sessionId; + this._status.daemonVersion = hello.daemonVersion; + this._status.capabilities = hello.capabilities; + } + + private setState(state: TransportState): void { + if (state === 'connected') { + this._status.needsPairing = false; + this._status.fatal = null; + this._status.retryAfterSec = null; + } + if (state !== 'connected') { + this._status.sessionId = null; + this._status.daemonVersion = null; + this._status.capabilities = []; + } + const changed = this._state !== state; + this._state = state; + this._status.state = state; + if (changed || state === 'disconnected') { + const snapshot = this.status; + for (const cb of this.stateListeners) { + try { + cb(snapshot); + } catch (err) { + console.error('[velox] state listener threw', err); + } + } + } + } + + private dispatchEvent(event: string, payload: unknown): void { + const set = this.listeners.get(event); + if (!set) return; + for (const cb of set) { + try { + cb(payload); + } catch (err) { + console.error(`[velox] listener for ${event} threw`, err); + } + } + } +} + +function safeClose(ws: MinimalWebSocket): void { + try { + ws.close(); + } catch { + /* already closing */ + } +} + +function describe(err: unknown): string { + if (err instanceof RpcError) return `rpc ${err.code}: ${err.message}`; + if (err instanceof Error) return err.message; + return String(err); +} diff --git a/extension/tests/.gitkeep b/extension/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/extension/tests/capture/headers.test.ts b/extension/tests/capture/headers.test.ts new file mode 100644 index 0000000..c1baf2d --- /dev/null +++ b/extension/tests/capture/headers.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + HeaderStash, + normalizeHeaders, + type OnBeforeSendHeadersDetails, + type WebRequestLike, +} from '../../src/background/capture/headers.js'; + +function details(over: Partial = {}): OnBeforeSendHeadersDetails { + return { + requestId: '1', + url: 'https://cdn.example.com/big.zip', + method: 'GET', + tabId: 7, + requestHeaders: [ + { name: 'User-Agent', value: 'Firefox' }, + { name: 'Referer', value: 'https://example.com/page' }, + { name: 'Cookie', value: 'sid=abc' }, + ], + ...over, + }; +} + +describe('normalizeHeaders', () => { + it('lower-cases names and drops valueless entries', () => { + expect( + normalizeHeaders([ + { name: 'Accept-Encoding', value: 'gzip' }, + { name: 'X-Empty' }, + ]), + ).toEqual({ 'accept-encoding': 'gzip' }); + }); + + it('joins repeated header names with ", "', () => { + expect( + normalizeHeaders([ + { name: 'X-Fwd', value: 'a' }, + { name: 'x-fwd', value: 'b' }, + ]), + ).toEqual({ 'x-fwd': 'a, b' }); + }); + + it('returns {} for missing headers', () => { + expect(normalizeHeaders(undefined)).toEqual({}); + }); +}); + +describe('HeaderStash', () => { + let now = 1_000_000; + const clock = () => now; + + afterEach(() => { + now = 1_000_000; + }); + + it('stashes and reads back normalized headers', () => { + const s = new HeaderStash({ now: clock, sweepIntervalMs: 0 }); + s.put(details()); + const got = s.peek('1'); + expect(got).toMatchObject({ + requestId: '1', + method: 'GET', + tabId: 7, + headers: { 'user-agent': 'Firefox', referer: 'https://example.com/page', cookie: 'sid=abc' }, + }); + }); + + it('take() reads once then the entry is gone', () => { + const s = new HeaderStash({ now: clock, sweepIntervalMs: 0 }); + s.put(details()); + expect(s.take('1')).toBeDefined(); + expect(s.take('1')).toBeUndefined(); + expect(s.size).toBe(0); + }); + + it('evicts the oldest entry past the size cap', () => { + const s = new HeaderStash({ now: clock, maxEntries: 2, sweepIntervalMs: 0 }); + s.put(details({ requestId: 'a' })); + s.put(details({ requestId: 'b' })); + s.put(details({ requestId: 'c' })); + expect(s.size).toBe(2); + expect(s.peek('a')).toBeUndefined(); + expect(s.peek('b')).toBeDefined(); + expect(s.peek('c')).toBeDefined(); + }); + + it('re-putting a requestId refreshes its recency and TTL (redirect case)', () => { + const s = new HeaderStash({ now: clock, maxEntries: 2, ttlMs: 1000, sweepIntervalMs: 0 }); + s.put(details({ requestId: 'a' })); + now += 10; + s.put(details({ requestId: 'b' })); + now += 10; + s.put(details({ requestId: 'a', url: 'https://cdn.example.com/redirected.zip' })); // a moves to newest + s.put(details({ requestId: 'c' })); // evicts the now-oldest, which is b + expect(s.peek('b')).toBeUndefined(); + expect(s.peek('a')?.url).toBe('https://cdn.example.com/redirected.zip'); + now += 995; // < 1000 since the re-put of a + expect(s.peek('a')).toBeDefined(); + }); + + it('treats an entry past its TTL as absent and deletes it on read', () => { + const s = new HeaderStash({ now: clock, ttlMs: 5 * 60_000, sweepIntervalMs: 0 }); + s.put(details()); + now += 5 * 60_000 + 1; + expect(s.peek('1')).toBeUndefined(); + expect(s.size).toBe(0); + }); + + it('sweep() drops expired entries and keeps fresh ones', () => { + const s = new HeaderStash({ now: clock, ttlMs: 1000, sweepIntervalMs: 0 }); + s.put(details({ requestId: 'old1' })); + s.put(details({ requestId: 'old2' })); + now += 1001; + s.put(details({ requestId: 'fresh' })); + expect(s.sweep()).toBe(2); + expect(s.size).toBe(1); + expect(s.peek('fresh')).toBeDefined(); + }); + + it('drop() forgets one request', () => { + const s = new HeaderStash({ now: clock, sweepIntervalMs: 0 }); + s.put(details({ requestId: 'x' })); + s.drop('x'); + expect(s.peek('x')).toBeUndefined(); + }); +}); + +// --- attach() wiring --------------------------------------------------------------- + +class FakeEvent void> { + listeners: L[] = []; + addListener = (l: L): void => void this.listeners.push(l); + removeListener = (l: L): void => { + this.listeners = this.listeners.filter((x) => x !== l); + }; + emit(...args: Parameters): void { + for (const l of this.listeners) (l as (...a: Parameters) => void)(...args); + } +} + +function fakeWebRequest() { + return { + onBeforeSendHeaders: new FakeEvent<(d: OnBeforeSendHeadersDetails) => void>(), + onCompleted: new FakeEvent<(d: { requestId: string }) => void>(), + onErrorOccurred: new FakeEvent<(d: { requestId: string }) => void>(), + }; +} + +describe('HeaderStash.attach', () => { + it('stashes on onBeforeSendHeaders and forgets on onCompleted / onErrorOccurred', () => { + const wr = fakeWebRequest(); + const s = new HeaderStash({ sweepIntervalMs: 0 }); + s.attach(wr as unknown as WebRequestLike); + + wr.onBeforeSendHeaders.emit(details({ requestId: 'done' })); + wr.onBeforeSendHeaders.emit(details({ requestId: 'failed' })); + expect(s.size).toBe(2); + + wr.onCompleted.emit({ requestId: 'done' }); + wr.onErrorOccurred.emit({ requestId: 'failed' }); + expect(s.size).toBe(0); + + s.detach(); + wr.onBeforeSendHeaders.emit(details({ requestId: 'after-detach' })); + expect(s.size).toBe(0); + expect(wr.onBeforeSendHeaders.listeners).toHaveLength(0); + }); + + it('runs a background sweep on its interval', () => { + vi.useFakeTimers(); + let t = 0; + const s = new HeaderStash({ now: () => t, ttlMs: 1000, sweepIntervalMs: 500 }); + const wr = fakeWebRequest(); + s.attach(wr as unknown as WebRequestLike); + wr.onBeforeSendHeaders.emit(details({ requestId: 'a' })); + + t = 2000; + vi.advanceTimersByTime(500); + expect(s.size).toBe(0); + + s.detach(); + vi.useRealTimers(); + }); + + it('refuses a double attach', () => { + const s = new HeaderStash({ sweepIntervalMs: 0 }); + const wr = fakeWebRequest(); + s.attach(wr as unknown as WebRequestLike); + expect(() => s.attach(wr as unknown as WebRequestLike)).toThrow(/already attached/); + s.detach(); + }); +}); diff --git a/extension/tests/capture/rules.test.ts b/extension/tests/capture/rules.test.ts new file mode 100644 index 0000000..69d374c --- /dev/null +++ b/extension/tests/capture/rules.test.ts @@ -0,0 +1,221 @@ +// shouldCapture() decision table — written before the implementation. +// +// The rule (docs/05 §2): capture when ANY positive signal holds AND NONE of the vetoes +// do. Too eager and we hijack page navigations; too shy and we are not a download +// manager. Every row here is a case that has to stay pinned. + +import { describe, expect, it } from 'vitest'; + +import type { CaptureRules, Headers } from '../../src/shared/protocol/index.js'; +import { shouldCapture, type CaptureCandidate } from '../../src/background/capture/rules.js'; + +const RULES: CaptureRules = { + enabled: true, + monitoredExtensions: ['zip', 'iso', 'mp4', 'dmg', 'pkg'], + monitoredMimeTypes: ['application/octet-stream', 'application/x-iso9660-image', 'video/mp4'], + minSizeBytes: 1024 * 1024, // 1 MiB + excludedHosts: ['mail.example.com', '*.internal.example.org'], + bypassModifier: 'alt', + rulesVersion: 1, +}; + +function candidate(over: Partial = {}): CaptureCandidate { + return { + url: 'https://cdn.example.com/files/report.bin', + method: 'GET', + statusCode: 200, + type: 'other', + responseHeaders: {}, + requestHeaders: {}, + documentUrl: 'https://example.com/downloads', + bypassHeld: false, + ...over, + }; +} + +const h = (o: Record): Headers => o; + +interface Row { + name: string; + candidate: CaptureCandidate; + capture: boolean; + reason: string; + rules?: CaptureRules; +} + +const TABLE: Row[] = [ + // --- positives --------------------------------------------------------------------- + { + name: 'Content-Disposition: attachment', + candidate: candidate({ + url: 'https://cdn.example.com/generate?id=9', + responseHeaders: h({ 'content-disposition': 'attachment; filename="q3.csv"', 'content-type': 'text/csv' }), + }), + capture: true, + reason: 'content_disposition', + }, + { + name: 'attachment on a main_frame is still a download, not a navigation', + candidate: candidate({ + type: 'main_frame', + responseHeaders: h({ 'content-disposition': 'attachment; filename="page.html"', 'content-type': 'text/html' }), + }), + capture: true, + reason: 'content_disposition', + }, + { + name: 'monitored file extension', + candidate: candidate({ url: 'https://cdn.example.com/a/ubuntu-26.04.iso?sig=abc' }), + capture: true, + reason: 'monitored_extension', + }, + { + name: 'monitored MIME type (not text/html)', + candidate: candidate({ responseHeaders: h({ 'content-type': 'application/octet-stream' }) }), + capture: true, + reason: 'monitored_mime', + }, + { + name: 'over the size threshold and not a renderable type', + candidate: candidate({ + responseHeaders: h({ 'content-type': 'application/x-tar', 'content-length': String(8 * 1024 * 1024) }), + }), + capture: true, + reason: 'over_min_size', + }, + + // --- vetoes ---------------------------------------------------------------------- + { + name: 'capture disabled in rules', + candidate: candidate({ url: 'https://cdn.example.com/a.zip' }), + rules: { ...RULES, enabled: false }, + capture: false, + reason: 'capture_disabled', + }, + { + name: 'excluded host (exact)', + candidate: candidate({ url: 'https://mail.example.com/attach/a.zip' }), + capture: false, + reason: 'excluded_host', + }, + { + name: 'excluded host (wildcard)', + candidate: candidate({ url: 'https://build07.internal.example.org/artifacts/out.zip' }), + capture: false, + reason: 'excluded_host', + }, + { + name: 'HTML page navigation', + candidate: candidate({ + type: 'main_frame', + responseHeaders: h({ 'content-type': 'text/html; charset=utf-8', 'content-length': String(4 * 1024 * 1024) }), + }), + capture: false, + reason: 'html_navigation', + }, + { + name: 'blob: document origin', + candidate: candidate({ url: 'https://cdn.example.com/a.zip', documentUrl: 'blob:https://example.com/uuid' }), + capture: false, + reason: 'blob_or_data_origin', + }, + { + name: 'the download URL itself is a blob:', + candidate: candidate({ url: 'blob:https://example.com/2b7f-...' }), + capture: false, + reason: 'blob_or_data_origin', + }, + { + name: 'bypass modifier held', + candidate: candidate({ url: 'https://cdn.example.com/a.zip', bypassHeld: true }), + capture: false, + reason: 'bypass_modifier', + }, + { + name: 'streaming media — HLS manifest MIME', + candidate: candidate({ + url: 'https://v.example.com/live/index.m3u8', + responseHeaders: h({ 'content-type': 'application/vnd.apple.mpegurl' }), + }), + capture: false, + reason: 'streaming_media', + }, + { + name: 'streaming media —