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:
2026-09-09 19:55:54 +04:00
co-authored by Claude Opus 5
parent a40585f419
commit 53421d6cb8
171 changed files with 29275 additions and 51 deletions
+331
View File
@@ -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 } };
}
+89
View File
@@ -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;
}
+218
View File
@@ -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 };
+145
View File
@@ -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';
}
}
+85
View File
@@ -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);
}
+88
View File
@@ -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;
}