merge: lane/ext
This commit is contained in:
@@ -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:<port>` where the port is discovered by trying a small fixed range and
|
||||
verifying a `session.hello` handshake — the extension cannot read
|
||||
|
||||
@@ -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 `[email protected]`, 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/<uid>/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": "<id>", "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 `<id>`
|
||||
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(<id>, {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/<firefox-pid>/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/<uid>`.
|
||||
|
||||
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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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": "[email protected]",
|
||||
"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": ["<all_urls>"]
|
||||
}
|
||||
Generated
+4915
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<L extends (...args: never[]) => 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 = '<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<string, StashedRequest>();
|
||||
private sweepTimer: ReturnType<typeof setInterval> | 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;
|
||||
}
|
||||
@@ -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() : '';
|
||||
}
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<typeof NativeTransport>[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<VeloxTransport> {
|
||||
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;
|
||||
}
|
||||
@@ -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<void> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly listeners = new Map<string, Set<EventListener>>();
|
||||
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<void> {
|
||||
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<M extends MethodName>(method: M, params: Params<M>, opts?: CallOptions): Promise<Result<M>> {
|
||||
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<Result<M>>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<string, unknown>);
|
||||
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<SessionHelloResult> {
|
||||
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 */
|
||||
}
|
||||
}
|
||||
@@ -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<typeof setTimeout>;
|
||||
signal?: AbortSignal;
|
||||
onAbort?: () => void;
|
||||
}
|
||||
|
||||
export class RpcConnection {
|
||||
private seq = 0;
|
||||
private readonly pending = new Map<number, Pending>();
|
||||
|
||||
constructor(private readonly send: (frame: JsonRpcRequest) => void) {}
|
||||
|
||||
get inFlight(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
|
||||
request(method: string, params: unknown, timeoutMs: number, signal?: AbortSignal): Promise<unknown> {
|
||||
this.seq += 1;
|
||||
const id = this.seq;
|
||||
return new Promise<unknown>((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<string, unknown>): 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();
|
||||
}
|
||||
}
|
||||
@@ -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<T>(key: string): Promise<T | undefined> {
|
||||
const bag = await browser.storage.local.get(key);
|
||||
return bag[key] as T | undefined;
|
||||
}
|
||||
|
||||
export async function getToken(): Promise<string | null> {
|
||||
return (await get<string>(KEY.token)) ?? null;
|
||||
}
|
||||
|
||||
export async function setToken(token: string | null): Promise<void> {
|
||||
if (token === null) await browser.storage.local.remove(KEY.token);
|
||||
else await browser.storage.local.set({ [KEY.token]: token });
|
||||
}
|
||||
|
||||
export async function getOverride(): Promise<TransportOverride> {
|
||||
const v = await get<string>(KEY.override);
|
||||
return v === 'ws' || v === 'uds' ? v : 'auto';
|
||||
}
|
||||
|
||||
export async function setOverride(value: TransportOverride): Promise<void> {
|
||||
await browser.storage.local.set({ [KEY.override]: value });
|
||||
}
|
||||
|
||||
export async function getCachedWsPort(): Promise<number | null> {
|
||||
const v = await get<number>(KEY.wsPort);
|
||||
return typeof v === 'number' ? v : null;
|
||||
}
|
||||
|
||||
export async function setCachedWsPort(port: number | null): Promise<void> {
|
||||
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://<uuid>/`.
|
||||
*/
|
||||
export function extensionOriginId(): string {
|
||||
return new URL(browser.runtime.getURL('/')).host;
|
||||
}
|
||||
@@ -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<void>;
|
||||
/** Stop for good: closes the socket and cancels any pending reconnect. */
|
||||
disconnect(): void;
|
||||
|
||||
call<M extends MethodName>(method: M, params: Params<M>, opts?: CallOptions): Promise<Result<M>>;
|
||||
|
||||
on<E extends EventName>(event: E, cb: (payload: EventPayload<E>) => 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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// WebSocketTransport — the primary path (docs/adr/0003).
|
||||
//
|
||||
// ws://127.0.0.1:<port> 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<string | null>;
|
||||
setToken(token: string | null): Promise<void>;
|
||||
/** Last-known-good port to try first, or null. */
|
||||
getCachedPort(): Promise<number | null>;
|
||||
setCachedPort(port: number | null): Promise<void>;
|
||||
/** 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<void> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly listeners = new Map<string, Set<EventListener>>();
|
||||
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<void> {
|
||||
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<M extends MethodName>(method: M, params: Params<M>, opts?: CallOptions): Promise<Result<M>> {
|
||||
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<Result<M>>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<PortOutcome> {
|
||||
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<PortOutcome> {
|
||||
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<HelloOutcome> {
|
||||
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<boolean> {
|
||||
return new Promise<boolean>((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);
|
||||
}
|
||||
@@ -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> = {}): 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<L extends (...a: never[]) => 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<L>): void {
|
||||
for (const l of this.listeners) (l as (...a: Parameters<L>) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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<string, string>): 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 — <video> element load (resourceType media)',
|
||||
candidate: candidate({
|
||||
url: 'https://v.example.com/seg/chunk.mp4',
|
||||
type: 'media',
|
||||
responseHeaders: h({ 'content-type': 'video/mp4', 'content-length': String(20 * 1024 * 1024) }),
|
||||
}),
|
||||
capture: false,
|
||||
reason: 'streaming_media',
|
||||
},
|
||||
{
|
||||
name: 'range request the page itself issued',
|
||||
candidate: candidate({
|
||||
url: 'https://cdn.example.com/big.iso',
|
||||
statusCode: 206,
|
||||
requestHeaders: h({ range: 'bytes=1048576-2097151' }),
|
||||
}),
|
||||
capture: false,
|
||||
reason: 'page_range_request',
|
||||
},
|
||||
{
|
||||
name: 'non-GET (form POST result) is left to the downloads.onCreated safety net',
|
||||
candidate: candidate({ method: 'POST', responseHeaders: h({ 'content-disposition': 'attachment' }) }),
|
||||
capture: false,
|
||||
reason: 'not_a_get',
|
||||
},
|
||||
{
|
||||
name: 'redirect status is not a body',
|
||||
candidate: candidate({ statusCode: 302, responseHeaders: h({ location: '/elsewhere' }) }),
|
||||
capture: false,
|
||||
reason: 'bad_status',
|
||||
},
|
||||
{
|
||||
name: 'small file below the threshold with no other signal',
|
||||
candidate: candidate({
|
||||
responseHeaders: h({ 'content-type': 'application/x-tar', 'content-length': String(4096) }),
|
||||
}),
|
||||
capture: false,
|
||||
reason: 'no_rule_matched',
|
||||
},
|
||||
{
|
||||
name: 'plain nothing — no disposition, unknown type, no length',
|
||||
candidate: candidate({ url: 'https://example.com/page/thing' }),
|
||||
capture: false,
|
||||
reason: 'no_rule_matched',
|
||||
},
|
||||
{
|
||||
name: 'large image is renderable — not captured by the size rule',
|
||||
candidate: candidate({
|
||||
url: 'https://cdn.example.com/photo',
|
||||
responseHeaders: h({ 'content-type': 'image/jpeg', 'content-length': String(6 * 1024 * 1024) }),
|
||||
}),
|
||||
capture: false,
|
||||
reason: 'no_rule_matched',
|
||||
},
|
||||
{
|
||||
name: 'a veto beats a positive: monitored .zip on an excluded host',
|
||||
candidate: candidate({ url: 'https://mail.example.com/a/backup.zip' }),
|
||||
capture: false,
|
||||
reason: 'excluded_host',
|
||||
},
|
||||
];
|
||||
|
||||
describe('shouldCapture', () => {
|
||||
for (const row of TABLE) {
|
||||
it(row.name, () => {
|
||||
const decision = shouldCapture(row.candidate, row.rules ?? RULES);
|
||||
expect(decision).toEqual({ capture: row.capture, reason: row.reason });
|
||||
});
|
||||
}
|
||||
|
||||
it('is a pure function of its inputs (no reliance on globals)', () => {
|
||||
const c = candidate({ url: 'https://cdn.example.com/x.zip' });
|
||||
const a = shouldCapture(c, RULES);
|
||||
const b = shouldCapture(c, RULES);
|
||||
expect(a).toEqual(b);
|
||||
expect(a.capture).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// Global test setup: a minimal stand-in for Firefox's `browser` global with an
|
||||
// in-memory storage.local, so storage.ts and the transport picker run under vitest.
|
||||
|
||||
import { beforeEach } from 'vitest';
|
||||
|
||||
function makeBrowserShim() {
|
||||
const store = new Map<string, unknown>();
|
||||
const local = {
|
||||
get: async (keys?: string | string[] | Record<string, unknown> | null) => {
|
||||
if (keys == null) return Object.fromEntries(store);
|
||||
const names =
|
||||
typeof keys === 'string' ? [keys] : Array.isArray(keys) ? keys : Object.keys(keys);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k of names) if (store.has(k)) out[k] = store.get(k);
|
||||
return out;
|
||||
},
|
||||
set: async (obj: Record<string, unknown>) => {
|
||||
for (const [k, v] of Object.entries(obj)) store.set(k, v);
|
||||
},
|
||||
remove: async (keys: string | string[]) => {
|
||||
for (const k of typeof keys === 'string' ? [keys] : keys) store.delete(k);
|
||||
},
|
||||
clear: async () => {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
return {
|
||||
storage: { local },
|
||||
runtime: {
|
||||
getURL: (path = '/') => `moz-extension://11111111-2222-3333-4444-555555555555${path}`,
|
||||
connectNative: () => {
|
||||
throw new Error('browser.runtime.connectNative was not stubbed for this test');
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).browser = makeBrowserShim();
|
||||
|
||||
beforeEach(async () => {
|
||||
await browser.storage.local.clear();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Backoff } from '../../src/background/transport/backoff.js';
|
||||
|
||||
describe('Backoff', () => {
|
||||
it('grows geometrically and caps, with no jitter', () => {
|
||||
const b = new Backoff({ baseMs: 500, factor: 2, maxMs: 8000, jitter: 0 });
|
||||
expect([b.next(), b.next(), b.next(), b.next(), b.next(), b.next()]).toEqual([
|
||||
500, 1000, 2000, 4000, 8000, 8000,
|
||||
]);
|
||||
});
|
||||
|
||||
it('reset() returns to the base delay', () => {
|
||||
const b = new Backoff({ baseMs: 100, factor: 3, jitter: 0 });
|
||||
b.next();
|
||||
b.next();
|
||||
expect(b.attempts).toBe(2);
|
||||
b.reset();
|
||||
expect(b.attempts).toBe(0);
|
||||
expect(b.next()).toBe(100);
|
||||
});
|
||||
|
||||
it('keeps jittered delays within +/- the jitter fraction', () => {
|
||||
const rand = [0, 0.5, 1];
|
||||
let i = 0;
|
||||
const b = new Backoff({ baseMs: 1000, factor: 1, maxMs: 1000, jitter: 0.2, random: () => rand[i++]! });
|
||||
expect(b.next()).toBe(800); // random 0 -> raw - spread
|
||||
expect(b.next()).toBe(1000); // random 0.5 -> raw
|
||||
expect(b.next()).toBe(1200); // random 1 -> raw + spread
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { candidatePorts, WS_PORT_RANGE } from '../../src/background/transport/discovery.js';
|
||||
|
||||
describe('candidatePorts', () => {
|
||||
it('covers 52000-52016 inclusive by default', () => {
|
||||
const ports = candidatePorts();
|
||||
expect(WS_PORT_RANGE).toEqual({ start: 52000, end: 52016 });
|
||||
expect(ports).toHaveLength(17);
|
||||
expect(ports[0]).toBe(52000);
|
||||
expect(ports.at(-1)).toBe(52016);
|
||||
});
|
||||
|
||||
it('tries the last-known-good port first, then the rest ascending, with no duplicate', () => {
|
||||
const ports = candidatePorts(WS_PORT_RANGE, 52009);
|
||||
expect(ports[0]).toBe(52009);
|
||||
expect(ports).toHaveLength(17);
|
||||
expect(ports.filter((p) => p === 52009)).toHaveLength(1);
|
||||
expect(ports.slice(1)).toEqual([...ports.slice(1)].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it('ignores a preferred port outside the range', () => {
|
||||
expect(candidatePorts(WS_PORT_RANGE, 40000)).toEqual(candidatePorts());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
// Test doubles for the transport suite: a real loopback JSON-RPC server for the
|
||||
// WebSocket transport, and a scriptable fake port for the native transport.
|
||||
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
|
||||
import { PROTOCOL_VERSION } from '../../src/shared/protocol/index.js';
|
||||
import type { NativePort } from '../../src/background/transport/native.js';
|
||||
|
||||
// --- a loopback veloxd, just enough of one ------------------------------------------
|
||||
|
||||
export interface FakeDaemonOptions {
|
||||
port: number;
|
||||
/** Token the daemon will accept on session.hello. null = accept none (forces pairing). */
|
||||
acceptToken?: string | null;
|
||||
/** session.hello answers -32001 before anything else. */
|
||||
versionMismatch?: boolean;
|
||||
/** session.pair answers this error instead of issuing a token. */
|
||||
pairError?: { code: number; message: string; data?: unknown };
|
||||
daemonVersion?: string;
|
||||
capabilities?: string[];
|
||||
/** Don't answer these methods at all, to exercise client timeouts. */
|
||||
blackhole?: string[];
|
||||
}
|
||||
|
||||
export class FakeDaemon {
|
||||
readonly wss: WebSocketServer;
|
||||
readonly sockets = new Set<WebSocket>();
|
||||
helloCount = 0;
|
||||
pairCount = 0;
|
||||
readonly seen: Array<{ method: string; params: unknown; token?: unknown }> = [];
|
||||
private issued = new Set<string>();
|
||||
private nextToken = 'tok-issued-1';
|
||||
|
||||
constructor(private readonly opts: FakeDaemonOptions) {
|
||||
if (opts.acceptToken) this.issued.add(opts.acceptToken);
|
||||
this.wss = new WebSocketServer({ host: '127.0.0.1', port: opts.port });
|
||||
this.wss.on('connection', (ws) => {
|
||||
this.sockets.add(ws);
|
||||
ws.on('close', () => this.sockets.delete(ws));
|
||||
ws.on('message', (raw) => this.onMessage(ws, String(raw)));
|
||||
});
|
||||
}
|
||||
|
||||
static async start(opts: FakeDaemonOptions): Promise<FakeDaemon> {
|
||||
const d = new FakeDaemon(opts);
|
||||
await new Promise<void>((r) => d.wss.once('listening', () => r()));
|
||||
return d;
|
||||
}
|
||||
|
||||
private send(ws: WebSocket, obj: unknown): void {
|
||||
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
private onMessage(ws: WebSocket, raw: string): void {
|
||||
let req: { id?: number; method?: string; params?: Record<string, unknown> };
|
||||
try {
|
||||
req = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { id, method, params = {} } = req;
|
||||
if (typeof method !== 'string') return;
|
||||
this.seen.push({ method, params, token: params['token'] });
|
||||
|
||||
if (this.opts.blackhole?.includes(method)) return;
|
||||
|
||||
const ok = (result: unknown): void => this.send(ws, { jsonrpc: '2.0', id, result });
|
||||
const err = (code: number, message: string, data?: unknown): void =>
|
||||
this.send(ws, { jsonrpc: '2.0', id, error: data === undefined ? { code, message } : { code, message, data } });
|
||||
|
||||
switch (method) {
|
||||
case 'session.hello': {
|
||||
this.helloCount += 1;
|
||||
if (this.opts.versionMismatch) {
|
||||
err(-32001, 'protocol major version mismatch', { expected: PROTOCOL_VERSION, actual: '9.0.0' });
|
||||
return;
|
||||
}
|
||||
const token = params['token'];
|
||||
if (typeof token !== 'string' || !this.issued.has(token)) {
|
||||
err(-32002, 'not paired: call session.pair first');
|
||||
return;
|
||||
}
|
||||
ok({
|
||||
daemonVersion: this.opts.daemonVersion ?? '1.2.3-fake',
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: this.opts.capabilities ?? ['media', 'grabber'],
|
||||
sessionId: 'sess-1',
|
||||
transport: 'ws',
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'session.pair': {
|
||||
this.pairCount += 1;
|
||||
if (this.opts.pairError) {
|
||||
err(this.opts.pairError.code, this.opts.pairError.message, this.opts.pairError.data);
|
||||
return;
|
||||
}
|
||||
const t = this.nextToken;
|
||||
this.issued.add(t);
|
||||
ok({ token: t, expiresAt: null });
|
||||
return;
|
||||
}
|
||||
case 'session.subscribe':
|
||||
ok({ ok: true, events: (params['events'] as string[]) ?? [] });
|
||||
return;
|
||||
case 'download.list':
|
||||
ok({ total: 0, items: [] });
|
||||
return;
|
||||
default:
|
||||
err(-32601, 'no such method');
|
||||
}
|
||||
}
|
||||
|
||||
/** Push a server-to-client notification to every open socket. */
|
||||
notify(method: string, paramsObj: unknown): void {
|
||||
for (const ws of this.sockets) this.send(ws, { jsonrpc: '2.0', method, params: paramsObj });
|
||||
}
|
||||
|
||||
/** Hard-drop every current connection, to exercise reconnect. */
|
||||
dropAll(): void {
|
||||
for (const ws of this.sockets) ws.terminate();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
for (const ws of this.sockets) ws.terminate();
|
||||
await new Promise<void>((r) => this.wss.close(() => r()));
|
||||
}
|
||||
}
|
||||
|
||||
/** A free-ish port inside a private band; each call is distinct within a run. */
|
||||
let portCursor = 53100 + Math.floor(Math.random() * 300);
|
||||
export function nextPort(): number {
|
||||
portCursor += 1;
|
||||
return portCursor;
|
||||
}
|
||||
|
||||
// --- scriptable native port -------------------------------------------------------
|
||||
|
||||
type Listener = (arg: unknown) => void;
|
||||
|
||||
export class FakeNativePort implements NativePort {
|
||||
error: { message: string } | null = null;
|
||||
readonly sent: unknown[] = [];
|
||||
private msgListeners = new Set<Listener>();
|
||||
private discListeners = new Set<Listener>();
|
||||
disconnected = false;
|
||||
|
||||
onMessage = {
|
||||
addListener: (cb: (m: unknown) => void) => this.msgListeners.add(cb as Listener),
|
||||
removeListener: (cb: (m: unknown) => void) => this.msgListeners.delete(cb as Listener),
|
||||
};
|
||||
onDisconnect = {
|
||||
addListener: (cb: (p?: unknown) => void) => this.discListeners.add(cb as Listener),
|
||||
removeListener: (cb: (p?: unknown) => void) => this.discListeners.delete(cb as Listener),
|
||||
};
|
||||
|
||||
/** Auto-answers session.hello unless `autoHello` is false. */
|
||||
constructor(private readonly opts: { autoHello?: boolean; helloError?: { code: number; message: string }; helloResult?: unknown } = {}) {}
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.sent.push(message);
|
||||
const req = message as { id?: number; method?: string };
|
||||
if (req.method === 'session.hello' && this.opts.autoHello !== false && !this.disconnected) {
|
||||
queueMicrotask(() => {
|
||||
if (this.disconnected) return;
|
||||
if (this.opts.helloError) {
|
||||
this.emitMessage({ jsonrpc: '2.0', id: req.id, error: this.opts.helloError });
|
||||
} else {
|
||||
this.emitMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: req.id,
|
||||
result: this.opts.helloResult ?? {
|
||||
daemonVersion: '1.2.3-nm',
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: ['media'],
|
||||
sessionId: 'nm-sess',
|
||||
transport: 'uds',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.disconnected = true;
|
||||
}
|
||||
|
||||
emitMessage(msg: unknown): void {
|
||||
for (const cb of this.msgListeners) cb(msg);
|
||||
}
|
||||
|
||||
fireDisconnect(errorMessage?: string): void {
|
||||
this.disconnected = true;
|
||||
if (errorMessage) this.error = { message: errorMessage };
|
||||
for (const cb of this.discListeners) cb(this);
|
||||
}
|
||||
}
|
||||
|
||||
export const tick = (ms = 0): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
|
||||
import { createTransport } from '../../src/background/transport/index.js';
|
||||
import type { WebSocketCtor } from '../../src/background/transport/websocket.js';
|
||||
import type { VeloxTransport } from '../../src/background/transport/types.js';
|
||||
import { FakeDaemon, FakeNativePort, nextPort, tick } from './helpers.js';
|
||||
|
||||
const CTOR = WsClient as unknown as WebSocketCtor;
|
||||
|
||||
let daemon: FakeDaemon | undefined;
|
||||
let transport: VeloxTransport | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
transport?.disconnect();
|
||||
transport = undefined;
|
||||
await daemon?.stop();
|
||||
daemon = undefined;
|
||||
});
|
||||
|
||||
describe('createTransport (runtime picker)', () => {
|
||||
it('override "ws" uses the WebSocket transport and never touches native messaging', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: null });
|
||||
let nativeTried = false;
|
||||
|
||||
transport = await createTransport({
|
||||
override: 'ws',
|
||||
webSocketCtor: CTOR,
|
||||
portRange: { start: port, end: port },
|
||||
connectNative: () => {
|
||||
nativeTried = true;
|
||||
return new FakeNativePort();
|
||||
},
|
||||
backoff: { baseMs: 20, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(transport.kind).toBe('ws');
|
||||
expect(transport.state).toBe('connected');
|
||||
expect(nativeTried).toBe(false);
|
||||
});
|
||||
|
||||
it('"auto" prefers native messaging when its handshake succeeds', async () => {
|
||||
transport = await createTransport({
|
||||
override: 'auto',
|
||||
connectNative: () => new FakeNativePort(),
|
||||
webSocketCtor: CTOR,
|
||||
portRange: { start: nextPort(), end: nextPort() },
|
||||
});
|
||||
expect(transport.kind).toBe('uds');
|
||||
expect(transport.state).toBe('connected');
|
||||
});
|
||||
|
||||
it('"auto" falls back to WebSocket when the native host is not installed', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: null });
|
||||
|
||||
transport = await createTransport({
|
||||
override: 'auto',
|
||||
webSocketCtor: CTOR,
|
||||
portRange: { start: port, end: port },
|
||||
connectNative: () => {
|
||||
const fp = new FakeNativePort({ autoHello: false });
|
||||
queueMicrotask(() => fp.fireDisconnect('No such native application com.velox.host'));
|
||||
return fp;
|
||||
},
|
||||
backoff: { baseMs: 20, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(transport.kind).toBe('ws');
|
||||
expect(transport.state).toBe('connected');
|
||||
});
|
||||
|
||||
it('reads the override from storage when none is passed', async () => {
|
||||
await browser.storage.local.set({ 'velox.transportOverride': 'ws' });
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: null });
|
||||
let nativeTried = false;
|
||||
|
||||
transport = await createTransport({
|
||||
webSocketCtor: CTOR,
|
||||
portRange: { start: port, end: port },
|
||||
connectNative: () => {
|
||||
nativeTried = true;
|
||||
return new FakeNativePort();
|
||||
},
|
||||
backoff: { baseMs: 20, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(transport.kind).toBe('ws');
|
||||
expect(nativeTried).toBe(false);
|
||||
});
|
||||
|
||||
it('override "uds" surfaces a native-host failure to the caller', async () => {
|
||||
await expect(
|
||||
createTransport({
|
||||
override: 'uds',
|
||||
connectNative: () => {
|
||||
const fp = new FakeNativePort({ autoHello: false });
|
||||
queueMicrotask(() => fp.fireDisconnect('No such native application com.velox.host'));
|
||||
return fp;
|
||||
},
|
||||
backoff: { baseMs: 10 },
|
||||
}),
|
||||
).rejects.toThrow(/not installed/);
|
||||
await tick(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
RpcError,
|
||||
RpcTimeoutError,
|
||||
TransportClosedError,
|
||||
} from '../../src/background/transport/types.js';
|
||||
import { NativeTransport } from '../../src/background/transport/native.js';
|
||||
import { FakeNativePort, tick } from './helpers.js';
|
||||
|
||||
let transport: NativeTransport | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
transport?.disconnect();
|
||||
transport = undefined;
|
||||
});
|
||||
|
||||
describe('NativeTransport', () => {
|
||||
it('completes session.hello over the port and connects', async () => {
|
||||
const port = new FakeNativePort();
|
||||
transport = new NativeTransport({ connectNative: () => port });
|
||||
|
||||
await transport.connect();
|
||||
|
||||
expect(transport.state).toBe('connected');
|
||||
expect(transport.kind).toBe('uds');
|
||||
expect(transport.status.daemonVersion).toBe('1.2.3-nm');
|
||||
expect(transport.status.capabilities).toEqual(['media']);
|
||||
expect((port.sent[0] as { method: string }).method).toBe('session.hello');
|
||||
expect((port.sent[0] as { params: { token: unknown } }).params.token).toBeNull();
|
||||
});
|
||||
|
||||
it('marks the transport fatal when the host is not installed', async () => {
|
||||
const port = new FakeNativePort({ autoHello: false });
|
||||
transport = new NativeTransport({ connectNative: () => port, backoff: { baseMs: 10 } });
|
||||
|
||||
const p = transport.connect();
|
||||
queueMicrotask(() => port.fireDisconnect('No such native application com.velox.host'));
|
||||
await expect(p).rejects.toBeInstanceOf(RpcError);
|
||||
|
||||
expect(transport.status.fatal).toMatch(/not installed/);
|
||||
// fatal => no reconnect
|
||||
const calls = [] as unknown[];
|
||||
const t2 = new NativeTransport({
|
||||
connectNative: () => {
|
||||
calls.push(1);
|
||||
const fp = new FakeNativePort({ autoHello: false });
|
||||
queueMicrotask(() => fp.fireDisconnect('No such native application'));
|
||||
return fp;
|
||||
},
|
||||
backoff: { baseMs: 10 },
|
||||
});
|
||||
await t2.connect().catch(() => undefined);
|
||||
await tick(60);
|
||||
expect(calls).toHaveLength(1);
|
||||
t2.disconnect();
|
||||
});
|
||||
|
||||
it('reconnects when an installed host drops during the handshake', async () => {
|
||||
let attempts = 0;
|
||||
transport = new NativeTransport({
|
||||
connectNative: () => {
|
||||
attempts += 1;
|
||||
const fp = new FakeNativePort({ autoHello: attempts > 1 });
|
||||
if (attempts === 1) queueMicrotask(() => fp.fireDisconnect('pipe closed'));
|
||||
return fp;
|
||||
},
|
||||
backoff: { baseMs: 10, factor: 1, jitter: 0 },
|
||||
});
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(TransportClosedError);
|
||||
for (let i = 0; i < 40 && transport.state !== 'connected'; i += 1) await tick(10);
|
||||
expect(attempts).toBeGreaterThanOrEqual(2);
|
||||
expect(transport.state).toBe('connected');
|
||||
});
|
||||
|
||||
it('treats a protocol-major mismatch as fatal', async () => {
|
||||
const port = new FakeNativePort({ helloError: { code: -32001, message: 'daemon speaks 9.x' } });
|
||||
transport = new NativeTransport({ connectNative: () => port });
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(RpcError);
|
||||
expect(transport.status.fatal).toMatch(/protocol mismatch/);
|
||||
});
|
||||
|
||||
it('forwards calls and fails them on a drop', async () => {
|
||||
const port = new FakeNativePort();
|
||||
transport = new NativeTransport({
|
||||
connectNative: () => port,
|
||||
backoff: { baseMs: 10_000 },
|
||||
});
|
||||
await transport.connect();
|
||||
|
||||
const pending = transport.call('download.list', { filter: null }, { timeoutMs: 5000 });
|
||||
expect((port.sent.at(-1) as { method: string }).method).toBe('download.list');
|
||||
port.fireDisconnect('host exited');
|
||||
await expect(pending).rejects.toBeInstanceOf(TransportClosedError);
|
||||
expect(transport.state).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('times a call out when the host goes quiet', async () => {
|
||||
const port = new FakeNativePort();
|
||||
transport = new NativeTransport({ connectNative: () => port });
|
||||
await transport.connect();
|
||||
await expect(
|
||||
transport.call('download.list', { filter: null }, { timeoutMs: 30 }),
|
||||
).rejects.toBeInstanceOf(RpcTimeoutError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RpcConnection } from '../../src/background/transport/rpc.js';
|
||||
import { RpcError, RpcTimeoutError } from '../../src/background/transport/types.js';
|
||||
|
||||
function makeConn() {
|
||||
const sent: Array<{ id: number; method: string; params: unknown }> = [];
|
||||
const conn = new RpcConnection((frame) => sent.push(frame));
|
||||
return { conn, sent };
|
||||
}
|
||||
|
||||
describe('RpcConnection', () => {
|
||||
it('resolves a call when a matching id comes back', async () => {
|
||||
const { conn, sent } = makeConn();
|
||||
const p = conn.request('download.list', { filter: null }, 1000);
|
||||
expect(sent[0]).toMatchObject({ id: 1, method: 'download.list' });
|
||||
conn.handleInbound({ jsonrpc: '2.0', id: 1, result: { total: 0, items: [] } });
|
||||
await expect(p).resolves.toEqual({ total: 0, items: [] });
|
||||
expect(conn.inFlight).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects with RpcError on an error frame, carrying code and data', async () => {
|
||||
const { conn } = makeConn();
|
||||
const p = conn.request('download.get', { taskId: 'x' }, 1000);
|
||||
conn.handleInbound({ jsonrpc: '2.0', id: 1, error: { code: -32010, message: 'no such task', data: { taskId: 'x' } } });
|
||||
await expect(p).rejects.toBeInstanceOf(RpcError);
|
||||
await p.catch((e: RpcError) => {
|
||||
expect(e.code).toBe(-32010);
|
||||
expect(e.data).toEqual({ taskId: 'x' });
|
||||
});
|
||||
});
|
||||
|
||||
it('matches replies to the right call when they arrive out of order', async () => {
|
||||
const { conn } = makeConn();
|
||||
const a = conn.request('download.list', {}, 1000);
|
||||
const b = conn.request('queue.list', {}, 1000);
|
||||
conn.handleInbound({ jsonrpc: '2.0', id: 2, result: 'B' });
|
||||
conn.handleInbound({ jsonrpc: '2.0', id: 1, result: 'A' });
|
||||
await expect(a).resolves.toBe('A');
|
||||
await expect(b).resolves.toBe('B');
|
||||
});
|
||||
|
||||
it('ignores a reply for an unknown id and returns it as non-notification', () => {
|
||||
const { conn } = makeConn();
|
||||
expect(conn.handleInbound({ jsonrpc: '2.0', id: 999, result: 1 })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns notifications (no id) for the caller to route', () => {
|
||||
const { conn } = makeConn();
|
||||
const note = conn.handleInbound({ jsonrpc: '2.0', method: 'event.task.progress', params: { tasks: [] } });
|
||||
expect(note).toEqual({ jsonrpc: '2.0', method: 'event.task.progress', params: { tasks: [] } });
|
||||
});
|
||||
|
||||
it('times out and then ignores the late reply', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { conn } = makeConn();
|
||||
const p = conn.request('capture.offer', {}, 750);
|
||||
const assertion = expect(p).rejects.toBeInstanceOf(RpcTimeoutError);
|
||||
await vi.advanceTimersByTimeAsync(751);
|
||||
await assertion;
|
||||
// a reply that shows up after the deadline must not throw or resolve anything
|
||||
expect(conn.handleInbound({ jsonrpc: '2.0', id: 1, result: 'too late' })).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('failAll rejects every pending call with the given reason', async () => {
|
||||
const { conn } = makeConn();
|
||||
const a = conn.request('download.list', {}, 1000);
|
||||
const b = conn.request('queue.list', {}, 1000);
|
||||
const reason = new Error('socket dropped');
|
||||
conn.failAll(reason);
|
||||
await expect(a).rejects.toBe(reason);
|
||||
await expect(b).rejects.toBe(reason);
|
||||
expect(conn.inFlight).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects when the AbortSignal fires and drops the pending entry', async () => {
|
||||
const { conn } = makeConn();
|
||||
const ac = new AbortController();
|
||||
const p = conn.request('download.probe', {}, 30_000, ac.signal);
|
||||
ac.abort(new Error('cancelled'));
|
||||
await expect(p).rejects.toThrow('cancelled');
|
||||
expect(conn.inFlight).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects synchronously-ish when the send callback throws', async () => {
|
||||
const conn = new RpcConnection(() => {
|
||||
throw new Error('not open');
|
||||
});
|
||||
await expect(conn.request('download.list', {}, 1000)).rejects.toThrow('not open');
|
||||
expect(conn.inFlight).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
|
||||
import {
|
||||
MethodNotAllowedError,
|
||||
RpcError,
|
||||
RpcTimeoutError,
|
||||
TransportClosedError,
|
||||
} from '../../src/background/transport/types.js';
|
||||
import {
|
||||
WebSocketTransport,
|
||||
type WebSocketCtor,
|
||||
type WebSocketTransportDeps,
|
||||
} from '../../src/background/transport/websocket.js';
|
||||
import { FakeDaemon, nextPort, tick } from './helpers.js';
|
||||
|
||||
const CTOR = WsClient as unknown as WebSocketCtor;
|
||||
|
||||
interface DepsHandle {
|
||||
deps: WebSocketTransportDeps;
|
||||
store: { token: string | null; port: number | null };
|
||||
}
|
||||
|
||||
function memDeps(init: Partial<{ token: string | null }> = {}): DepsHandle {
|
||||
const store = { token: init.token ?? null, port: null as number | null };
|
||||
return {
|
||||
store,
|
||||
deps: {
|
||||
getToken: async () => store.token,
|
||||
setToken: async (t) => {
|
||||
store.token = t;
|
||||
},
|
||||
getCachedPort: async () => store.port,
|
||||
setCachedPort: async (p) => {
|
||||
store.port = p;
|
||||
},
|
||||
extensionId: '11111111-2222-3333-4444-555555555555',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTransport(port: number, h: DepsHandle, extra: Partial<WebSocketTransportDeps> = {}) {
|
||||
return new WebSocketTransport({
|
||||
...h.deps,
|
||||
...extra,
|
||||
webSocketCtor: CTOR,
|
||||
portRange: { start: port, end: port },
|
||||
openTimeoutMs: 500,
|
||||
backoff: { baseMs: 20, factor: 2, maxMs: 120, jitter: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
let daemon: FakeDaemon | undefined;
|
||||
let transport: WebSocketTransport | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
transport?.disconnect();
|
||||
transport = undefined;
|
||||
await daemon?.stop();
|
||||
daemon = undefined;
|
||||
});
|
||||
|
||||
describe('WebSocketTransport', () => {
|
||||
it('discovers the port, completes session.hello, and reports daemon info', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 'good-token' });
|
||||
const h = memDeps({ token: 'good-token' });
|
||||
transport = makeTransport(port, h);
|
||||
|
||||
await transport.connect();
|
||||
|
||||
expect(transport.state).toBe('connected');
|
||||
expect(transport.status.daemonVersion).toBe('1.2.3-fake');
|
||||
expect(transport.status.capabilities).toEqual(['media', 'grabber']);
|
||||
expect(transport.status.sessionId).toBe('sess-1');
|
||||
expect(h.store.port).toBe(port); // cached for next time
|
||||
expect(daemon.pairCount).toBe(0); // had a valid token, no pairing needed
|
||||
});
|
||||
|
||||
it('pairs when unpaired, stores the token, and re-hellos with it', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: null });
|
||||
const h = memDeps();
|
||||
transport = makeTransport(port, h);
|
||||
|
||||
await transport.connect();
|
||||
|
||||
expect(transport.state).toBe('connected');
|
||||
expect(daemon.pairCount).toBe(1);
|
||||
expect(h.store.token).toBe('tok-issued-1'); // persisted
|
||||
expect(daemon.helloCount).toBe(2); // once unpaired, once with the fresh token
|
||||
const lastHello = [...daemon.seen].reverse().find((s) => s.method === 'session.hello');
|
||||
expect(lastHello?.token).toBe('tok-issued-1');
|
||||
});
|
||||
|
||||
it('with autoPair off, a wrong token surfaces needsPairing and does NOT retry', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 'the-real-one' });
|
||||
const h = memDeps({ token: 'stale-token' });
|
||||
transport = makeTransport(port, h, { autoPair: false });
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(RpcError);
|
||||
expect(transport.state).toBe('disconnected');
|
||||
expect(transport.status.needsPairing).toBe(true);
|
||||
|
||||
const helloAfterReject = daemon.helloCount;
|
||||
await tick(150); // longer than the (tiny) backoff would be
|
||||
expect(daemon.helloCount).toBe(helloAfterReject); // no reconnect storm
|
||||
});
|
||||
|
||||
it('treats a pairing rate-limit as needsPairing with a retry hint, no retry', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({
|
||||
port,
|
||||
acceptToken: null,
|
||||
pairError: { code: -32014, message: 'locked out', data: { retryAfterSec: 60 } },
|
||||
});
|
||||
transport = makeTransport(port, memDeps());
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(RpcError);
|
||||
expect(transport.status.needsPairing).toBe(true);
|
||||
expect(transport.status.retryAfterSec).toBe(60);
|
||||
const n = daemon.helloCount;
|
||||
await tick(150);
|
||||
expect(daemon.helloCount).toBe(n);
|
||||
});
|
||||
|
||||
it('treats a protocol-major mismatch as fatal and stops retrying', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, versionMismatch: true });
|
||||
transport = makeTransport(port, memDeps({ token: 'x' }));
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(RpcError);
|
||||
expect(transport.status.fatal).toMatch(/protocol mismatch/);
|
||||
const n = daemon.helloCount;
|
||||
await tick(150);
|
||||
expect(daemon.helloCount).toBe(n);
|
||||
});
|
||||
|
||||
it('forwards a call and returns the result', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't' });
|
||||
transport = makeTransport(port, memDeps({ token: 't' }));
|
||||
await transport.connect();
|
||||
|
||||
await expect(transport.call('download.list', { filter: null })).resolves.toEqual({
|
||||
total: 0,
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a call with RpcTimeoutError when the daemon never answers', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't', blackhole: ['download.list'] });
|
||||
transport = makeTransport(port, memDeps({ token: 't' }));
|
||||
await transport.connect();
|
||||
|
||||
await expect(
|
||||
transport.call('download.list', { filter: null }, { timeoutMs: 40 }),
|
||||
).rejects.toBeInstanceOf(RpcTimeoutError);
|
||||
});
|
||||
|
||||
it('rejects a uds-only method locally, before it hits the wire', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't' });
|
||||
transport = makeTransport(port, memDeps({ token: 't' }));
|
||||
await transport.connect();
|
||||
|
||||
const before = daemon.seen.length;
|
||||
await expect(
|
||||
transport.call('settings.set', { values: { 'capture.enabled': false } }),
|
||||
).rejects.toBeInstanceOf(MethodNotAllowedError);
|
||||
expect(daemon.seen.length).toBe(before);
|
||||
});
|
||||
|
||||
it('delivers server notifications to on() listeners', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't' });
|
||||
transport = makeTransport(port, memDeps({ token: 't' }));
|
||||
await transport.connect();
|
||||
|
||||
const seen: unknown[] = [];
|
||||
transport.on('event.task.progress', (p) => seen.push(p));
|
||||
daemon.notify('event.task.progress', { tasks: [{ taskId: 'a', downloadedBytes: 1 }] });
|
||||
await tick(20);
|
||||
expect(seen).toEqual([{ tasks: [{ taskId: 'a', downloadedBytes: 1 }] }]);
|
||||
});
|
||||
|
||||
it('fails in-flight calls on a drop and then reconnects on its own', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't', blackhole: ['download.list'] });
|
||||
transport = makeTransport(port, memDeps({ token: 't' }));
|
||||
await transport.connect();
|
||||
|
||||
const pending = transport.call('download.list', { filter: null }, { timeoutMs: 5000 });
|
||||
daemon.dropAll();
|
||||
await expect(pending).rejects.toBeInstanceOf(TransportClosedError);
|
||||
expect(transport.state).toBe('disconnected');
|
||||
|
||||
// backoff base is 20ms; give it room to come back
|
||||
for (let i = 0; i < 40 && transport.state !== 'connected'; i += 1) await tick(15);
|
||||
expect(transport.state).toBe('connected');
|
||||
});
|
||||
|
||||
it('disconnect() stops all reconnection', async () => {
|
||||
const port = nextPort();
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't' });
|
||||
transport = makeTransport(port, memDeps({ token: 't' }));
|
||||
await transport.connect();
|
||||
|
||||
transport.disconnect();
|
||||
daemon.dropAll();
|
||||
const n = daemon.helloCount;
|
||||
await tick(150);
|
||||
expect(daemon.helloCount).toBe(n);
|
||||
expect(transport.state).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('when nothing answers, connect() rejects but keeps retrying in the background', async () => {
|
||||
const port = nextPort(); // no daemon on it
|
||||
const h = memDeps({ token: 't' });
|
||||
transport = makeTransport(port, h);
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(TransportClosedError);
|
||||
expect(transport.state).toBe('disconnected');
|
||||
expect(transport.status.fatal).toBeNull();
|
||||
expect(transport.status.needsPairing).toBe(false);
|
||||
|
||||
// now bring a daemon up on that port; the transport should find it
|
||||
daemon = await FakeDaemon.start({ port, acceptToken: 't' });
|
||||
for (let i = 0; i < 60 && transport.state !== 'connected'; i += 1) await tick(15);
|
||||
expect(transport.state).toBe('connected');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node", "firefox-webext-browser"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
setupFiles: ['tests/setup.ts'],
|
||||
// The transport's reconnect timers are real; keep a test from hanging the suite.
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user