proto: freeze the wire contract at 1.0.0
Schemas for the whole v1 surface: 38 methods, 9 events, 25 named types and the
JSON-RPC envelope, with x-privileged / x-transports / x-deadlineMs / x-errors
annotations that both generators emit as data rather than prose.
Four generators over one IR (contracts/codegen/schema_ir.py), so the C++ structs,
the TypeScript types and the OpenRPC document cannot disagree about what the
contract says:
gen_cpp.py -> core/generated/velox_proto.{hpp,cpp}
gen_ts.py -> extension/src/shared/protocol/
gen_openrpc.py -> contracts/openrpc.json
gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp
Inbound parsing never throws: parse<T>() returns std::expected<T, ParseError> and
nlohmann's throwing ADL from_json is deliberately not emitted. Schema constraints
(minimum, maxLength, pattern, ...) become real runtime checks in both languages —
the daemon does not trust the extension and the extension does not trust the
daemon.
59 golden fixtures: a success case per method, 12 error cases, 9 events. Replayed
by tests/conformance/ against both the generated C++ and a live server over both
transports. tools/mockd serves the same fixtures with unhappy-path flags so the
GUI and EXT lanes never wait for veloxd.
run.sh also proves capture.offer fails open: with a daemon answering slower than
750 ms the client gives up and lets Firefox take the download.
core/generated/ is libveloxproto, a separate target from libveloxcore, which
still never sees JSON — see docs/adr/0009.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# tools/mockd — a fake `veloxd`
|
||||
|
||||
Serves `contracts/fixtures` over **both** transports, keeps just enough state that adding
|
||||
and pausing a download does something visible, and fakes progress events at 4 Hz.
|
||||
|
||||
The GUI and extension lanes develop against this from day one and never wait for the real
|
||||
daemon. Its unhappy-path flags exist so those lanes can test the cases that are hard to
|
||||
arrange on purpose — a slow daemon, a flaky one, a dropped socket, a refused pairing.
|
||||
|
||||
```sh
|
||||
cd tools/mockd
|
||||
npm install
|
||||
npm start -- --help
|
||||
npm start # both transports, default paths
|
||||
```
|
||||
|
||||
Defaults: `$XDG_RUNTIME_DIR/velox/velox.sock` and `ws://127.0.0.1:52000`.
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `--uds <path>` / `--no-uds` | Unix socket path, or don't listen |
|
||||
| `--ws-port <n>` / `--no-ws` | loopback WebSocket port, or don't listen |
|
||||
| `--progress-hz <n>` | progress event rate (default 4, the contract's ceiling) |
|
||||
| `--speed <bytes>` | synthetic per-task speed |
|
||||
| `--slow <ms>` | delay every reply. **Past 750 ms `capture.offer` must fail open.** |
|
||||
| `--flaky <0..1>` | answer this fraction of calls with `-32603` |
|
||||
| `--drop-connection <s>` | terminate every connection every N seconds |
|
||||
| `--refuse-pairing` | `session.pair` fails, as if the user clicked Deny |
|
||||
| `--lockout` | `session.pair` answers `-32014`, as if the brute-force lockout tripped |
|
||||
| `--allowed-root <dir>` | add a root that `download.add`'s `saveDir` may resolve inside |
|
||||
| `--allow-any-origin` | skip the `moz-extension://` Origin check (debugging only) |
|
||||
| `--no-validate` | stop validating params (to see what a client actually sends) |
|
||||
|
||||
## What is real and what is faked
|
||||
|
||||
**Real**, because a client's correctness depends on it:
|
||||
|
||||
* transport and privilege rules, taken from the *generated* `METHODS` table — so a
|
||||
privileged method is refused with `-32003` over the WebSocket exactly as `veloxd` must;
|
||||
* param validation, through the *generated* validators, including range and length checks;
|
||||
* `saveDir` canonicalization against the allowed roots, so `-32011` is reachable;
|
||||
* the `capture.offer` decision — monitored types, minimum size, excluded hosts — so both
|
||||
the take and the ignore paths get exercised;
|
||||
* pairing: `session.hello` accepts only a token this process actually issued;
|
||||
* task state, so add / pause / resume / cancel / remove do what a client expects to see.
|
||||
|
||||
**Faked**: bytes advance on a clock, not from a socket. There is no network, no disk, and
|
||||
no engine. Anything not listed above is answered from its golden fixture.
|
||||
|
||||
## Why it imports the generated protocol code
|
||||
|
||||
`mockd` uses `extension/src/shared/protocol/` — the generated TypeScript — rather than
|
||||
types of its own. A mock with hand-written types is a third source of truth, and it drifts.
|
||||
This way a schema change that breaks a client breaks `mockd` in the same commit.
|
||||
|
||||
It imports the individual generated modules (`types.js`, `methods.js`, …) rather than
|
||||
`index.js`, because the `extension/` tree has no `package.json` of its own for Node to
|
||||
resolve a star re-export through. That is a quirk of running from outside that package, not
|
||||
a problem with the generated code; the extension's own bundler is unaffected.
|
||||
Generated
+573
@@ -0,0 +1,573 @@
|
||||
{
|
||||
"name": "@velox/mockd",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@velox/mockd",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"bin": {
|
||||
"mockd": "src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.13",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
|
||||
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@velox/mockd",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Mock veloxd. Serves contracts/fixtures over both transports so the GUI and extension lanes never wait for the real daemon.",
|
||||
"type": "module",
|
||||
"bin": { "mockd": "./src/index.ts" },
|
||||
"scripts": {
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"engines": { "node": ">=20" }
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Turning a request into a reply.
|
||||
*
|
||||
* Two sources of truth, in order:
|
||||
* 1. A handler here, for the methods a client needs to *behave* (add a task and watch it
|
||||
* appear, pause it and watch it stop).
|
||||
* 2. The golden fixture for that method, otherwise.
|
||||
*
|
||||
* Transport and privilege rules come from the generated METHODS table, not from a list
|
||||
* retyped here — so mockd refuses exactly what the real daemon must refuse, and the EXT
|
||||
* lane can test its -32003 handling before veloxd exists.
|
||||
*/
|
||||
|
||||
// Imported from the individual generated modules rather than index.js: the extension
|
||||
// tree has no package.json of its own, so Node cannot re-export * through it from here.
|
||||
import { ErrorCode } from '../../../extension/src/shared/protocol/types.js';
|
||||
import {
|
||||
METHODS,
|
||||
isMethodName,
|
||||
type MethodName,
|
||||
type Transport,
|
||||
} from '../../../extension/src/shared/protocol/methods.js';
|
||||
import { validateParams } from '../../../extension/src/shared/protocol/validate.js';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { resolvePlaceholders, type Fixture } from './fixtures.js';
|
||||
import type { MockState } from './state.js';
|
||||
|
||||
export interface Session {
|
||||
readonly transport: Transport;
|
||||
paired: boolean;
|
||||
subscribed: Set<string>;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
readonly protocolVersion: string;
|
||||
readonly daemonVersion: string;
|
||||
readonly refusePairing: boolean;
|
||||
readonly flaky: number;
|
||||
readonly validate: boolean;
|
||||
/** Every write target must canonicalize inside one of these. */
|
||||
readonly allowedRoots: readonly string[];
|
||||
/** Answer session.pair with -32014, as if the brute-force lockout had tripped. */
|
||||
readonly lockout: boolean;
|
||||
}
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
export function rpcError(id: unknown, code: number, message: string, data?: Json): Json {
|
||||
const error: Json = { code, message };
|
||||
if (data) error['data'] = data;
|
||||
return { jsonrpc: '2.0', id: id ?? null, error };
|
||||
}
|
||||
|
||||
function rpcResult(id: unknown, result: unknown): Json {
|
||||
return { jsonrpc: '2.0', id: id ?? null, result };
|
||||
}
|
||||
|
||||
export class Dispatcher {
|
||||
/** Tokens this mockd has issued. A token it never minted is not a valid token. */
|
||||
private readonly issuedTokens = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly state: MockState,
|
||||
private readonly fixtures: Map<string, Fixture>,
|
||||
private readonly opts: Options,
|
||||
) {}
|
||||
|
||||
handle(session: Session, frame: unknown): Json | null {
|
||||
if (typeof frame !== 'object' || frame === null || Array.isArray(frame)) {
|
||||
return rpcError(null, ErrorCode.InvalidRequest, 'not a JSON-RPC 2.0 request');
|
||||
}
|
||||
const req = frame as Json;
|
||||
const id = req['id'];
|
||||
if (req['jsonrpc'] !== '2.0' || typeof req['method'] !== 'string') {
|
||||
return rpcError(id, ErrorCode.InvalidRequest, 'not a JSON-RPC 2.0 request');
|
||||
}
|
||||
|
||||
const method = req['method'];
|
||||
if (!isMethodName(method)) {
|
||||
return rpcError(id, ErrorCode.MethodNotFound, 'no such method');
|
||||
}
|
||||
if (!(METHODS[method].transports as readonly string[]).includes(session.transport)) {
|
||||
return rpcError(id, ErrorCode.TransportForbidden, 'method is not permitted on this transport');
|
||||
}
|
||||
if (session.transport === 'ws' && !session.paired && method !== 'session.pair' && method !== 'session.hello') {
|
||||
return rpcError(id, ErrorCode.NotPaired, 'not paired: call session.pair first');
|
||||
}
|
||||
|
||||
const params = (req['params'] ?? {}) as unknown;
|
||||
if (this.opts.validate) {
|
||||
const v = validateParams(method, params);
|
||||
if (!v.ok) {
|
||||
return rpcError(id, ErrorCode.InvalidParams, `${v.path}: ${v.message}`, { path: v.path });
|
||||
}
|
||||
}
|
||||
|
||||
// --flaky turns a fraction of otherwise-good calls into internal errors, so clients
|
||||
// exercise their retry and error paths without a hostile server.
|
||||
if (this.opts.flaky > 0 && Math.random() < this.opts.flaky && method !== 'session.hello') {
|
||||
return rpcError(id, ErrorCode.InternalError, 'synthetic failure (--flaky)');
|
||||
}
|
||||
|
||||
const result = this.route(session, method, params as Json);
|
||||
if (result && typeof result === 'object' && 'code' in result && 'message' in result) {
|
||||
return { jsonrpc: '2.0', id: id ?? null, error: result };
|
||||
}
|
||||
return rpcResult(id, result);
|
||||
}
|
||||
|
||||
/** Returns a result, or an error object ({code, message}) to be wrapped by the caller. */
|
||||
private route(session: Session, method: MethodName, params: Json): unknown {
|
||||
const state = this.state;
|
||||
|
||||
switch (method) {
|
||||
case 'session.hello': {
|
||||
if (session.transport === 'ws') {
|
||||
const token = params['token'];
|
||||
// The reply does not distinguish absent from malformed from merely wrong: an
|
||||
// unpaired caller learns nothing it could use to guess.
|
||||
if (typeof token !== 'string' || !this.issuedTokens.has(token)) {
|
||||
return { code: ErrorCode.NotPaired, message: 'not paired: call session.pair first' };
|
||||
}
|
||||
session.paired = true;
|
||||
}
|
||||
const requested = String(params['protocolVersion'] ?? '');
|
||||
if (requested.split('.')[0] !== this.opts.protocolVersion.split('.')[0]) {
|
||||
return {
|
||||
code: ErrorCode.VersionMismatch,
|
||||
message: `protocol major version mismatch: daemon speaks ${this.opts.protocolVersion.split('.')[0]}.x, client speaks ${requested.split('.')[0]}.x`,
|
||||
data: { expected: this.opts.protocolVersion, actual: requested },
|
||||
};
|
||||
}
|
||||
return {
|
||||
daemonVersion: this.opts.daemonVersion,
|
||||
protocolVersion: this.opts.protocolVersion,
|
||||
capabilities: ['media', 'grabber', 'secretservice'],
|
||||
sessionId: session.sessionId,
|
||||
transport: session.transport,
|
||||
};
|
||||
}
|
||||
|
||||
case 'session.pair': {
|
||||
if (this.opts.lockout) {
|
||||
return { code: ErrorCode.RateLimited, message: 'too many pairing attempts; try again later',
|
||||
data: { retryAfterSec: 60 } };
|
||||
}
|
||||
if (this.opts.refusePairing) {
|
||||
return { code: ErrorCode.NotPaired, message: 'pairing was declined by the user' };
|
||||
}
|
||||
session.paired = true;
|
||||
const token = Buffer.from(session.sessionId + session.sessionId).toString('base64url');
|
||||
this.issuedTokens.add(token);
|
||||
return { token, expiresAt: null };
|
||||
}
|
||||
|
||||
case 'session.subscribe': {
|
||||
const events = (params['events'] as string[] | undefined) ?? [];
|
||||
session.subscribed = new Set(events);
|
||||
return { ok: true, events };
|
||||
}
|
||||
|
||||
case 'download.list': {
|
||||
const filter = (params['filter'] ?? null) as Json | null;
|
||||
let items = state.list();
|
||||
const states = filter?.['states'] as string[] | undefined;
|
||||
if (states) items = items.filter((t) => states.includes(t.state));
|
||||
const categoryId = filter?.['categoryId'] as string | undefined;
|
||||
if (categoryId) items = items.filter((t) => t.categoryId === categoryId);
|
||||
const total = items.length;
|
||||
const offset = Number(params['offset'] ?? 0);
|
||||
const limit = Number(params['limit'] ?? 500);
|
||||
return { total, items: items.slice(offset, offset + limit) };
|
||||
}
|
||||
|
||||
case 'download.get': {
|
||||
const task = state.tasks.get(String(params['taskId']));
|
||||
if (!task) return notFound(String(params['taskId']));
|
||||
const fixture = this.fixtureResult('download.get') as Json;
|
||||
return { ...fixture, summary: task };
|
||||
}
|
||||
|
||||
case 'download.add': {
|
||||
// Canonicalize before checking, so ../ traversal cannot smuggle a write out of the
|
||||
// allowed roots. This is intrinsic to the request, so mockd answers it exactly as
|
||||
// veloxd must, and the -32011 fixture is replayable against both.
|
||||
const requested = params['saveDir'] as string | null | undefined;
|
||||
if (typeof requested === 'string' && !this.withinAllowedRoots(requested)) {
|
||||
return { code: ErrorCode.InvalidPath, message: 'destination is outside the allowed roots',
|
||||
data: { path: requested } };
|
||||
}
|
||||
const task = state.add({
|
||||
url: String(params['url']),
|
||||
filename: (params['filename'] as string | null) ?? undefined,
|
||||
saveDir: (params['saveDir'] as string | null) ?? undefined,
|
||||
categoryId: (params['categoryId'] as string | null) ?? undefined,
|
||||
segments: (params['segments'] as number | null) ?? undefined,
|
||||
state: params['startMode'] === 'later' ? 'paused'
|
||||
: params['startMode'] === 'queue' ? 'queued' : 'connecting',
|
||||
});
|
||||
return { taskId: task.taskId, state: task.state, duplicate: null };
|
||||
}
|
||||
|
||||
case 'download.start':
|
||||
case 'download.pause':
|
||||
case 'download.resume':
|
||||
case 'download.cancel': {
|
||||
const target = { 'download.start': 'connecting', 'download.pause': 'paused',
|
||||
'download.resume': 'connecting', 'download.cancel': 'cancelled' }[method] as
|
||||
'connecting' | 'paused' | 'cancelled';
|
||||
const updated: Json[] = [];
|
||||
const failed: Json[] = [];
|
||||
for (const raw of (params['taskIds'] as string[]) ?? []) {
|
||||
const r = state.transition(raw, target);
|
||||
if (!r) failed.push({ taskId: raw, code: ErrorCode.TaskNotFound, message: 'no such task' });
|
||||
else updated.push({ taskId: raw, state: r.task.state, changed: r.changed });
|
||||
}
|
||||
return { updated, failed };
|
||||
}
|
||||
|
||||
case 'download.remove': {
|
||||
const removed: string[] = [];
|
||||
const failed: Json[] = [];
|
||||
for (const raw of (params['taskIds'] as string[]) ?? []) {
|
||||
if (state.remove(raw)) removed.push(raw);
|
||||
else failed.push({ taskId: raw, code: ErrorCode.TaskNotFound, message: 'no such task' });
|
||||
}
|
||||
return { removed, failed };
|
||||
}
|
||||
|
||||
case 'download.update': {
|
||||
const task = state.tasks.get(String(params['taskId']));
|
||||
if (!task) return notFound(String(params['taskId']));
|
||||
const patch = (params['patch'] ?? {}) as Json;
|
||||
for (const key of ['filename', 'saveDir', 'categoryId', 'queueId', 'description', 'segments'] as const) {
|
||||
if (key in patch) (task as unknown as Json)[key] = patch[key];
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
case 'capture.offer': {
|
||||
// A real decision rather than a canned reply, so the extension's take and ignore
|
||||
// paths both get exercised. The daemon owns this policy; capture.getRules is how
|
||||
// the extension mirrors it.
|
||||
const rules = this.fixtureResult('capture.getRules') as {
|
||||
enabled: boolean; monitoredExtensions: string[]; monitoredMimeTypes: string[];
|
||||
minSizeBytes: number; excludedHosts: string[];
|
||||
};
|
||||
const url = String(params['url'] ?? '');
|
||||
const length = params['contentLength'] as number | null | undefined;
|
||||
const contentType = (params['contentType'] as string | null) ?? '';
|
||||
const disposition = (params['contentDisposition'] as string | null) ?? '';
|
||||
const ext = url.split('?')[0]?.split('.').pop()?.toLowerCase() ?? '';
|
||||
let host = '';
|
||||
try { host = new URL(url).hostname; } catch { host = ''; }
|
||||
|
||||
const excluded = rules.excludedHosts.some(
|
||||
(pattern) => pattern.startsWith('*.')
|
||||
? host.endsWith(pattern.slice(1)) || host === pattern.slice(2)
|
||||
: host === pattern);
|
||||
|
||||
const ignore = (reason: string): Json => ({ action: 'ignore', taskId: null, reason });
|
||||
if (!rules.enabled) return ignore('capture_disabled');
|
||||
if (excluded) return ignore('excluded_host');
|
||||
|
||||
const monitored = rules.monitoredExtensions.includes(ext)
|
||||
|| rules.monitoredMimeTypes.includes(contentType)
|
||||
|| disposition.startsWith('attachment');
|
||||
if (!monitored) return ignore('type_not_monitored');
|
||||
if (typeof length === 'number' && length < rules.minSizeBytes) return ignore('below_min_size');
|
||||
|
||||
const task = state.add({
|
||||
url,
|
||||
filename: (params['filename'] as string | null) ?? undefined,
|
||||
sizeBytes: typeof length === 'number' ? length : undefined,
|
||||
state: 'connecting',
|
||||
});
|
||||
return { action: 'take', taskId: task.taskId, reason: null };
|
||||
}
|
||||
|
||||
case 'limiter.get':
|
||||
return state.limiter;
|
||||
|
||||
case 'limiter.set': {
|
||||
state.limiter = {
|
||||
enabled: Boolean(params['enabled']),
|
||||
globalBps: Number(params['globalBps'] ?? state.limiter.globalBps),
|
||||
applyToRunning: Boolean(params['applyToRunning']),
|
||||
};
|
||||
return state.limiter;
|
||||
}
|
||||
|
||||
case 'settings.get': {
|
||||
const base = this.fixtureResult('settings.get') as { values: Record<string, unknown> };
|
||||
const values = { ...base.values, ...state.settings };
|
||||
const keys = params['keys'] as string[] | null | undefined;
|
||||
if (!keys) return { values };
|
||||
const picked: Record<string, unknown> = {};
|
||||
for (const k of keys) if (k in values) picked[k] = values[k];
|
||||
return { values: picked };
|
||||
}
|
||||
|
||||
case 'settings.set': {
|
||||
const values = (params['values'] ?? {}) as Record<string, unknown>;
|
||||
Object.assign(state.settings, values);
|
||||
return { values, changed: Object.keys(values) };
|
||||
}
|
||||
|
||||
default:
|
||||
return this.fixtureResult(method);
|
||||
}
|
||||
}
|
||||
|
||||
private withinAllowedRoots(dir: string): boolean {
|
||||
const resolved = resolvePath(dir);
|
||||
return this.opts.allowedRoots.some((root) => resolved === root || resolved.startsWith(root + '/'));
|
||||
}
|
||||
|
||||
/** The golden result for a method, with placeholders resolved fresh each time. */
|
||||
fixtureResult(method: string): unknown {
|
||||
const fixture = this.fixtures.get(method);
|
||||
if (!fixture || !fixture.response || !('result' in fixture.response)) {
|
||||
return { code: ErrorCode.InternalError, message: `mockd has no fixture for ${method}` };
|
||||
}
|
||||
return resolvePlaceholders(fixture.response.result);
|
||||
}
|
||||
}
|
||||
|
||||
function notFound(taskId: string): Json {
|
||||
return { code: ErrorCode.TaskNotFound, message: 'no such task', data: { taskId } };
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Loading and replaying contracts/fixtures.
|
||||
*
|
||||
* mockd answers from the golden files rather than from hand-written mock objects, so a
|
||||
* GUI or extension built against it is built against the same bytes the conformance suite
|
||||
* replays at the real daemon. If a fixture is wrong, everyone finds out at once.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export interface Fixture {
|
||||
readonly file: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly kind?: 'timeout';
|
||||
readonly transport?: 'uds' | 'ws';
|
||||
readonly request?: { jsonrpc: '2.0'; id: number | string; method: string; params?: unknown };
|
||||
readonly notification?: { jsonrpc: '2.0'; method: string; params: unknown };
|
||||
readonly response?: { jsonrpc: '2.0'; id: number | string; result?: unknown; error?: unknown } | null;
|
||||
readonly assertions?: readonly string[];
|
||||
}
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) out.push(...walk(full));
|
||||
else if (entry.endsWith('.json')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function loadFixtures(root: string): Fixture[] {
|
||||
return walk(root).map((file) => ({
|
||||
...(JSON.parse(readFileSync(file, 'utf8')) as Omit<Fixture, 'file'>),
|
||||
file: relative(root, file),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholders stand for values a golden file cannot pin: a fresh uuid, the current time,
|
||||
* an opaque token. Resolving them here is what lets one fixture be replayed forever.
|
||||
*/
|
||||
export function resolvePlaceholders(value: unknown): unknown {
|
||||
if (typeof value === 'string') {
|
||||
switch (value) {
|
||||
case '$uuid':
|
||||
return randomUUID();
|
||||
case '$isoDate':
|
||||
return new Date().toISOString();
|
||||
case '$opaque':
|
||||
return Buffer.from(randomUUID() + randomUUID()).toString('base64url');
|
||||
case '$any':
|
||||
return 'placeholder';
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(resolvePlaceholders);
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = resolvePlaceholders(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Success fixtures indexed by method, so a request can be answered from a golden file. */
|
||||
export function indexByMethod(fixtures: readonly Fixture[]): Map<string, Fixture> {
|
||||
const out = new Map<string, Fixture>();
|
||||
for (const f of fixtures) {
|
||||
const method = f.request?.method;
|
||||
if (!method || !f.response || !('result' in f.response)) continue;
|
||||
if (!out.has(method)) out.set(method, f);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function eventFixtures(fixtures: readonly Fixture[]): Map<string, Fixture> {
|
||||
const out = new Map<string, Fixture>();
|
||||
for (const f of fixtures) {
|
||||
if (f.notification) out.set(f.notification.method, f);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env -S npx tsx
|
||||
/**
|
||||
* mockd — a fake veloxd that is good enough to build a GUI and an extension against.
|
||||
*
|
||||
* It serves contracts/fixtures over both transports, keeps just enough state that adding
|
||||
* and pausing a download does something visible, and fakes progress events at 4 Hz. Its
|
||||
* unhappy-path flags exist so the GUI and EXT lanes can test the cases that are hard to
|
||||
* arrange on purpose — a slow daemon, a flaky one, a dropped socket, a refused pairing —
|
||||
* long before the real daemon exists.
|
||||
*
|
||||
* npm start -- --help
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { rmSync } from 'node:fs';
|
||||
|
||||
import { Dispatcher, type Session } from './dispatch.js';
|
||||
import { indexByMethod, loadFixtures, resolvePlaceholders } from './fixtures.js';
|
||||
import { MockState } from './state.js';
|
||||
import { startUds, type Connection } from './transport/uds.js';
|
||||
import { startWs } from './transport/ws.js';
|
||||
import type { TaskSummary } from '../../../extension/src/shared/protocol/types.js';
|
||||
import { PROTOCOL_VERSION } from '../../../extension/src/shared/protocol/types.js';
|
||||
|
||||
const HERE = resolve(fileURLToPath(import.meta.url), '..');
|
||||
const REPO = resolve(HERE, '..', '..', '..');
|
||||
const FIXTURE_DIR = resolve(REPO, 'contracts', 'fixtures');
|
||||
|
||||
const USAGE = `mockd — mock veloxd, serving contracts/fixtures over both transports
|
||||
|
||||
--uds <path> Unix socket path
|
||||
(default: $XDG_RUNTIME_DIR/velox/velox.sock)
|
||||
--ws-port <n> loopback WebSocket port (default: 52000)
|
||||
--no-uds do not listen on the Unix socket
|
||||
--no-ws do not listen on the WebSocket
|
||||
--progress-hz <n> progress event rate (default: 4, the contract's maximum)
|
||||
--speed <bytes> synthetic per-task speed in bytes/sec (default: 8388608)
|
||||
|
||||
Unhappy paths, for the GUI and EXT lanes:
|
||||
--slow <ms> delay every reply by <ms>. Past 750 ms, capture.offer must
|
||||
fail open and let Firefox take the download.
|
||||
--flaky <0..1> answer this fraction of calls with -32603
|
||||
--drop-connection <s> terminate every connection every <s> seconds
|
||||
--refuse-pairing session.pair always fails, as if the user clicked Deny
|
||||
--lockout session.pair answers -32014, as if the brute-force lockout tripped
|
||||
--allowed-root <dir> add a root that download.add's saveDir may resolve inside
|
||||
--allow-any-origin skip the moz-extension:// Origin check (debugging only)
|
||||
--no-validate do not validate params against the generated validators
|
||||
|
||||
-h, --help this message
|
||||
`;
|
||||
|
||||
interface Args {
|
||||
uds: string | null;
|
||||
wsPort: number | null;
|
||||
progressHz: number;
|
||||
speed: number;
|
||||
slow: number;
|
||||
flaky: number;
|
||||
dropEverySec: number;
|
||||
refusePairing: boolean;
|
||||
allowAnyOrigin: boolean;
|
||||
validate: boolean;
|
||||
lockout: boolean;
|
||||
allowedRoots: string[];
|
||||
}
|
||||
|
||||
function parseArgs(argv: readonly string[]): Args {
|
||||
const runtime = process.env['XDG_RUNTIME_DIR'] ?? `/run/user/${process.getuid?.() ?? 1000}`;
|
||||
const args: Args = {
|
||||
uds: resolve(runtime, 'velox', 'velox.sock'),
|
||||
wsPort: 52000,
|
||||
progressHz: 4,
|
||||
speed: 8 * 1024 * 1024,
|
||||
slow: 0,
|
||||
flaky: 0,
|
||||
dropEverySec: 0,
|
||||
refusePairing: false,
|
||||
allowAnyOrigin: false,
|
||||
validate: true,
|
||||
lockout: false,
|
||||
allowedRoots: [resolve(process.env['HOME'] ?? '/home/sami', 'Downloads'), '/tmp'],
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
const next = (): string => {
|
||||
const v = argv[i + 1];
|
||||
if (v === undefined) throw new Error(`${arg} needs a value`);
|
||||
i += 1;
|
||||
return v;
|
||||
};
|
||||
switch (arg) {
|
||||
case '-h': case '--help': process.stdout.write(USAGE); process.exit(0); break;
|
||||
case '--uds': args.uds = next(); break;
|
||||
case '--no-uds': args.uds = null; break;
|
||||
case '--ws-port': args.wsPort = Number(next()); break;
|
||||
case '--no-ws': args.wsPort = null; break;
|
||||
case '--progress-hz': args.progressHz = Number(next()); break;
|
||||
case '--speed': args.speed = Number(next()); break;
|
||||
case '--slow': args.slow = Number(next()); break;
|
||||
case '--flaky': args.flaky = Number(next()); break;
|
||||
case '--drop-connection': args.dropEverySec = Number(next()); break;
|
||||
case '--refuse-pairing': args.refusePairing = true; break;
|
||||
case '--lockout': args.lockout = true; break;
|
||||
case '--allowed-root': args.allowedRoots.push(resolve(next())); break;
|
||||
case '--allow-any-origin': args.allowAnyOrigin = true; break;
|
||||
case '--no-validate': args.validate = false; break;
|
||||
default:
|
||||
process.stderr.write(`mockd: unknown option ${arg}\n\n${USAGE}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const log = (msg: string): void => {
|
||||
process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
|
||||
};
|
||||
|
||||
const fixtures = loadFixtures(FIXTURE_DIR);
|
||||
const byMethod = indexByMethod(fixtures);
|
||||
log(`loaded ${fixtures.length} fixtures covering ${byMethod.size} methods from contracts/fixtures`);
|
||||
|
||||
// Seed the task list from the download.list fixture, so a client that connects before
|
||||
// adding anything still has rows to draw.
|
||||
const listFixture = byMethod.get('download.list');
|
||||
const seed = (resolvePlaceholders(
|
||||
(listFixture?.response as { result?: { items?: unknown[] } } | undefined)?.result?.items ?? [],
|
||||
) as TaskSummary[]);
|
||||
|
||||
const state = new MockState(seed, { progressHz: args.progressHz, speedBps: args.speed });
|
||||
const dispatcher = new Dispatcher(state, byMethod, {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
daemonVersion: '0.0.0-mockd',
|
||||
refusePairing: args.refusePairing,
|
||||
flaky: args.flaky,
|
||||
validate: args.validate,
|
||||
allowedRoots: args.allowedRoots,
|
||||
lockout: args.lockout,
|
||||
});
|
||||
|
||||
const connections = new Set<Connection>();
|
||||
const broadcast = (method: string, params: unknown): void => {
|
||||
const frame = { jsonrpc: '2.0', method, params };
|
||||
for (const conn of connections) {
|
||||
if (conn.session.subscribed.has(method)) conn.send(frame);
|
||||
}
|
||||
};
|
||||
|
||||
if (args.uds !== null) {
|
||||
startUds(args.uds, dispatcher, connections, log, args.slow);
|
||||
const path = args.uds;
|
||||
const cleanup = (): void => {
|
||||
rmSync(path, { force: true });
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', cleanup);
|
||||
process.on('SIGTERM', cleanup);
|
||||
}
|
||||
if (args.wsPort !== null) {
|
||||
startWs({ port: args.wsPort, delayMs: args.slow, dropEverySec: args.dropEverySec,
|
||||
allowAnyOrigin: args.allowAnyOrigin }, dispatcher, connections, log);
|
||||
}
|
||||
|
||||
// Progress at the contract's 4 Hz ceiling, as one batched array — never one
|
||||
// notification per task. The GUI lane needs this shape to build its coalescing against.
|
||||
setInterval(() => {
|
||||
const { moved, completed } = state.tick();
|
||||
if (moved.length > 0) {
|
||||
broadcast('event.task.progress', {
|
||||
tasks: moved.map((t) => ({
|
||||
taskId: t.taskId,
|
||||
downloadedBytes: t.downloadedBytes,
|
||||
speedBps: t.speedBps,
|
||||
etaSeconds: t.etaSeconds,
|
||||
segments: Array.from({ length: Math.min(t.segments, 8) }, (_, i) => ({
|
||||
index: i,
|
||||
downloadedBytes: Math.floor(t.downloadedBytes / t.segments),
|
||||
speedBps: Math.floor(t.speedBps / t.segments),
|
||||
})),
|
||||
})),
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
for (const task of completed) {
|
||||
broadcast('event.task.state', {
|
||||
taskId: task.taskId, state: 'complete', previousState: 'downloading',
|
||||
summary: task, error: null,
|
||||
});
|
||||
broadcast('event.notify', {
|
||||
level: 'success', title: 'Download complete',
|
||||
body: `${task.filename} finished.`, taskId: task.taskId, sound: 'complete',
|
||||
});
|
||||
}
|
||||
}, Math.max(1, Math.round(1000 / args.progressHz)));
|
||||
|
||||
setInterval(() => {
|
||||
const speed = state.globalSpeed();
|
||||
broadcast('event.speed.global', {
|
||||
...speed,
|
||||
limitBps: state.limiter.enabled ? state.limiter.globalBps : null,
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
log(`protocol v${PROTOCOL_VERSION}; ${state.tasks.size} seeded task(s)`);
|
||||
if (args.slow > 750) {
|
||||
log(`WARNING --slow ${args.slow} exceeds capture.offer's 750 ms deadline: a correct extension will fail open`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
// Referenced so the Session type stays exported for transport implementations.
|
||||
export type { Session };
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* The little bit of real state mockd keeps.
|
||||
*
|
||||
* A pure fixture replayer would be useless to the GUI lane: adding a download and seeing
|
||||
* nothing appear teaches you nothing about your table model. So mockd keeps a task list,
|
||||
* moves tasks between states, and advances byte counters on a timer. Everything else is
|
||||
* answered straight from a golden file.
|
||||
*
|
||||
* This is deliberately not a download engine. Bytes advance on a clock, not from a socket.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { TaskState, TaskSummary } from '../../../extension/src/shared/protocol/types.js';
|
||||
|
||||
export interface MockOptions {
|
||||
readonly progressHz: number;
|
||||
readonly speedBps: number;
|
||||
}
|
||||
|
||||
const ACTIVE: readonly TaskState[] = ['connecting', 'downloading'];
|
||||
|
||||
export class MockState {
|
||||
readonly tasks = new Map<string, TaskSummary>();
|
||||
limiter = { enabled: false, globalBps: 2097152, applyToRunning: false };
|
||||
settings: Record<string, unknown> = {};
|
||||
private readonly opts: MockOptions;
|
||||
|
||||
constructor(seed: readonly TaskSummary[], opts: MockOptions) {
|
||||
this.opts = opts;
|
||||
for (const t of seed) this.tasks.set(t.taskId, { ...t });
|
||||
}
|
||||
|
||||
list(): TaskSummary[] {
|
||||
return [...this.tasks.values()];
|
||||
}
|
||||
|
||||
add(partial: Partial<TaskSummary> & { url: string }): TaskSummary {
|
||||
const now = new Date().toISOString();
|
||||
const task: TaskSummary = {
|
||||
taskId: randomUUID(),
|
||||
filename: partial.filename ?? filenameFromUrl(partial.url),
|
||||
saveDir: partial.saveDir ?? '/home/sami/Downloads',
|
||||
url: partial.url,
|
||||
effectiveUrl: partial.url,
|
||||
sizeBytes: partial.sizeBytes ?? 734003200,
|
||||
downloadedBytes: 0,
|
||||
state: partial.state ?? 'connecting',
|
||||
speedBps: 0,
|
||||
etaSeconds: null,
|
||||
resumable: true,
|
||||
segments: partial.segments ?? 8,
|
||||
categoryId: partial.categoryId ?? null,
|
||||
queueId: partial.queueId ?? null,
|
||||
queuePosition: partial.queuePosition ?? null,
|
||||
description: partial.description ?? null,
|
||||
createdAt: now,
|
||||
lastTryAt: now,
|
||||
completedAt: null,
|
||||
error: null,
|
||||
};
|
||||
this.tasks.set(task.taskId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
transition(taskId: string, state: TaskState): { changed: boolean; task: TaskSummary } | null {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task) return null;
|
||||
const changed = task.state !== state;
|
||||
task.state = state;
|
||||
task.speedBps = ACTIVE.includes(state) ? this.opts.speedBps : 0;
|
||||
if (!ACTIVE.includes(state)) task.etaSeconds = null;
|
||||
if (state === 'complete') {
|
||||
task.downloadedBytes = task.sizeBytes ?? task.downloadedBytes;
|
||||
task.completedAt = new Date().toISOString();
|
||||
}
|
||||
return { changed, task };
|
||||
}
|
||||
|
||||
remove(taskId: string): boolean {
|
||||
return this.tasks.delete(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance one progress tick. Returns the tasks that moved and any that just finished,
|
||||
* so the caller can emit event.task.progress and event.task.state from one place.
|
||||
*/
|
||||
tick(): { moved: TaskSummary[]; completed: TaskSummary[] } {
|
||||
const moved: TaskSummary[] = [];
|
||||
const completed: TaskSummary[] = [];
|
||||
const perTick = Math.floor(this.opts.speedBps / this.opts.progressHz);
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.state === 'connecting') {
|
||||
task.state = 'downloading';
|
||||
moved.push(task);
|
||||
}
|
||||
if (task.state !== 'downloading') continue;
|
||||
|
||||
task.speedBps = jitter(perTick * this.opts.progressHz);
|
||||
task.downloadedBytes += perTick;
|
||||
const size = task.sizeBytes;
|
||||
if (size !== null && size !== undefined && task.downloadedBytes >= size) {
|
||||
task.downloadedBytes = size;
|
||||
task.state = 'complete';
|
||||
task.speedBps = 0;
|
||||
task.etaSeconds = null;
|
||||
task.completedAt = new Date().toISOString();
|
||||
completed.push(task);
|
||||
} else if (size !== null && size !== undefined && task.speedBps > 0) {
|
||||
task.etaSeconds = Math.ceil((size - task.downloadedBytes) / task.speedBps);
|
||||
}
|
||||
moved.push(task);
|
||||
}
|
||||
return { moved, completed };
|
||||
}
|
||||
|
||||
globalSpeed(): { downBps: number; activeCount: number; queuedCount: number } {
|
||||
let downBps = 0;
|
||||
let activeCount = 0;
|
||||
let queuedCount = 0;
|
||||
for (const t of this.tasks.values()) {
|
||||
if (ACTIVE.includes(t.state)) {
|
||||
downBps += t.speedBps;
|
||||
activeCount += 1;
|
||||
} else if (t.state === 'queued') {
|
||||
queuedCount += 1;
|
||||
}
|
||||
}
|
||||
return { downBps, activeCount, queuedCount };
|
||||
}
|
||||
}
|
||||
|
||||
function jitter(bps: number): number {
|
||||
return Math.max(0, Math.round(bps * (0.85 + Math.random() * 0.3)));
|
||||
}
|
||||
|
||||
function filenameFromUrl(url: string): string {
|
||||
try {
|
||||
const path = new URL(url).pathname;
|
||||
const last = path.split('/').filter(Boolean).pop();
|
||||
return last && last.length > 0 ? decodeURIComponent(last) : 'download.bin';
|
||||
} catch {
|
||||
return 'download.bin';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Unix socket transport: newline-delimited JSON, one request per line.
|
||||
*
|
||||
* This is what the GUI, the CLI and velox-nmhost speak. The real daemon checks
|
||||
* SO_PEERCRED here and needs no token; mockd does the same by simply trusting the socket,
|
||||
* because anything that can open it is already the same user.
|
||||
*/
|
||||
|
||||
import { createServer, type Server, type Socket } from 'node:net';
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Dispatcher, Session } from '../dispatch.js';
|
||||
|
||||
export interface Connection {
|
||||
send(frame: unknown): void;
|
||||
readonly session: Session;
|
||||
}
|
||||
|
||||
export function startUds(
|
||||
path: string,
|
||||
dispatcher: Dispatcher,
|
||||
connections: Set<Connection>,
|
||||
log: (msg: string) => void,
|
||||
delayMs: number,
|
||||
): Server {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
rmSync(path, { force: true });
|
||||
|
||||
const server = createServer((socket: Socket) => {
|
||||
const session: Session = { transport: 'uds', paired: true, subscribed: new Set(), sessionId: randomUUID() };
|
||||
const conn: Connection = {
|
||||
session,
|
||||
send: (frame) => {
|
||||
if (!socket.destroyed) socket.write(JSON.stringify(frame) + '\n');
|
||||
},
|
||||
};
|
||||
connections.add(conn);
|
||||
log(`uds: client connected (${connections.size} open)`);
|
||||
|
||||
let buffer = '';
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
let nl = buffer.indexOf('\n');
|
||||
while (nl !== -1) {
|
||||
const line = buffer.slice(0, nl).trim();
|
||||
buffer = buffer.slice(nl + 1);
|
||||
nl = buffer.indexOf('\n');
|
||||
if (line.length === 0) continue;
|
||||
handleLine(line, conn, dispatcher, log, delayMs);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (err) => log(`uds: socket error: ${err.message}`));
|
||||
socket.on('close', () => {
|
||||
connections.delete(conn);
|
||||
log(`uds: client disconnected (${connections.size} open)`);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(path, () => log(`uds: listening on ${path}`));
|
||||
return server;
|
||||
}
|
||||
|
||||
function handleLine(
|
||||
line: string,
|
||||
conn: Connection,
|
||||
dispatcher: Dispatcher,
|
||||
log: (msg: string) => void,
|
||||
delayMs: number,
|
||||
): void {
|
||||
let frame: unknown;
|
||||
try {
|
||||
frame = JSON.parse(line);
|
||||
} catch {
|
||||
conn.send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
|
||||
return;
|
||||
}
|
||||
const reply = dispatcher.handle(conn.session, frame);
|
||||
if (!reply) return;
|
||||
const method = (frame as { method?: string }).method ?? '?';
|
||||
log(`uds: ${method} -> ${'error' in reply ? `error ${(reply['error'] as { code: number }).code}` : 'ok'}`);
|
||||
if (delayMs > 0) setTimeout(() => conn.send(reply), delayMs);
|
||||
else conn.send(reply);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Loopback WebSocket transport: one JSON message per text frame.
|
||||
*
|
||||
* The extension's fallback for snap-confined Firefox, and the reason the security rules in
|
||||
* docs/05 exist: this port is reachable by every process on the machine. mockd enforces
|
||||
* the two that a client can actually observe — bind 127.0.0.1 only, and check the Origin —
|
||||
* so the EXT lane finds out here rather than against the real daemon.
|
||||
*/
|
||||
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Dispatcher, Session } from '../dispatch.js';
|
||||
import type { Connection } from './uds.js';
|
||||
|
||||
export interface WsOptions {
|
||||
readonly port: number;
|
||||
readonly delayMs: number;
|
||||
/** Drop every connection every N seconds, to exercise reconnect logic. */
|
||||
readonly dropEverySec: number;
|
||||
readonly allowAnyOrigin: boolean;
|
||||
}
|
||||
|
||||
export function startWs(
|
||||
opts: WsOptions,
|
||||
dispatcher: Dispatcher,
|
||||
connections: Set<Connection>,
|
||||
log: (msg: string) => void,
|
||||
): WebSocketServer {
|
||||
const server = new WebSocketServer({
|
||||
host: '127.0.0.1', // never 0.0.0.0 — see docs/05-extension-spec.md §4
|
||||
port: opts.port,
|
||||
verifyClient: ({ origin }, done) => {
|
||||
const ok = opts.allowAnyOrigin || origin === undefined || origin.startsWith('moz-extension://');
|
||||
if (!ok) log(`ws: refused connection from origin ${origin}`);
|
||||
done(ok, 403, 'origin not allowed');
|
||||
},
|
||||
});
|
||||
|
||||
server.on('listening', () => log(`ws: listening on ws://127.0.0.1:${opts.port}`));
|
||||
|
||||
server.on('connection', (socket: WebSocket) => {
|
||||
const session: Session = {
|
||||
transport: 'ws',
|
||||
paired: false, // the extension must pair or present a token first
|
||||
subscribed: new Set(),
|
||||
sessionId: randomUUID(),
|
||||
};
|
||||
const conn: Connection = {
|
||||
session,
|
||||
send: (frame) => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(frame));
|
||||
},
|
||||
};
|
||||
connections.add(conn);
|
||||
log(`ws: client connected (${connections.size} open)`);
|
||||
|
||||
socket.on('message', (data) => {
|
||||
let frame: unknown;
|
||||
try {
|
||||
frame = JSON.parse(data.toString());
|
||||
} catch {
|
||||
conn.send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
|
||||
return;
|
||||
}
|
||||
const reply = dispatcher.handle(session, frame);
|
||||
if (!reply) return;
|
||||
const method = (frame as { method?: string }).method ?? '?';
|
||||
log(`ws: ${method} -> ${'error' in reply ? `error ${(reply['error'] as { code: number }).code}` : 'ok'}`);
|
||||
if (opts.delayMs > 0) setTimeout(() => conn.send(reply), opts.delayMs);
|
||||
else conn.send(reply);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
connections.delete(conn);
|
||||
log(`ws: client disconnected (${connections.size} open)`);
|
||||
});
|
||||
socket.on('error', (err) => log(`ws: socket error: ${err.message}`));
|
||||
});
|
||||
|
||||
if (opts.dropEverySec > 0) {
|
||||
setInterval(() => {
|
||||
log(`ws: dropping ${server.clients.size} connection(s) (--drop-connection)`);
|
||||
for (const client of server.clients) client.terminate();
|
||||
}, opts.dropEverySec * 1000).unref();
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitOverride": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "../../extension/src/shared/protocol/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user