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,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 };
|
||||
Reference in New Issue
Block a user