#!/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 { DEFAULT_ACTIVE_CAP, generateSyntheticTasks } from './synth.js'; import { startUds, type Connection } from './transport/uds.js'; import { startWs } from './transport/ws.js'; import type { Category, Queue, 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 Unix socket path (default: $XDG_RUNTIME_DIR/velox/velox.sock) --ws-port loopback WebSocket port (default: 52000) --no-uds do not listen on the Unix socket --no-ws do not listen on the WebSocket --progress-hz progress event rate (default: 4, the contract's maximum) --speed synthetic per-task speed in bytes/sec (default: 8388608) Load testing, for the GUI lane's "N rows at 60 fps" definition of done: --tasks seed n plausible synthetic tasks — varied states, sizes, speeds, categories and queue positions — instead of the two from contracts/fixtures/download.list.json. A bounded, rotating pool of active downloads (see --active-cap) keeps progress moving across a broad slice of the table for as long as mockd runs, not just the first few rows. e.g. mockd --tasks 10000 --active-cap ceiling on concurrently-"downloading" synthetic tasks (default: 24). Has no effect on a manual download.add, which is always admitted immediately. --seed PRNG seed for --tasks, so a given seed reproduces the exact same table (default: 1337) Unhappy paths, for the GUI and EXT lanes: --slow delay every reply by . 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 terminate every connection every 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 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; tasks: number; activeCap: number; seed: 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, tasks: 0, activeCap: DEFAULT_ACTIVE_CAP, seed: 1337, 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 '--tasks': args.tasks = Number(next()); break; case '--active-cap': args.activeCap = Number(next()); break; case '--seed': args.seed = 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. --tasks replaces this with a much larger // synthetic population instead — see below. const listFixture = byMethod.get('download.list'); const seed = args.tasks > 0 ? [] : (resolvePlaceholders( (listFixture?.response as { result?: { items?: unknown[] } } | undefined)?.result?.items ?? [], ) as TaskSummary[]); const state = new MockState(seed, { progressHz: args.progressHz, speedBps: args.speed, activeCap: args.activeCap, }); if (args.tasks > 0) { // Categories and queues come from their own golden fixtures rather than being // reinvented here, so a --tasks run can never drift from what category.list and // queue.list actually serve — the extension's default-category picker and the GUI's // category tree see the same ids these synthetic tasks are filed under. const categories = (resolvePlaceholders( (byMethod.get('category.list')?.response as { result?: { items?: unknown[] } } | undefined) ?.result?.items ?? [], ) as Category[]); const queues = (resolvePlaceholders( (byMethod.get('queue.list')?.response as { result?: { items?: unknown[] } } | undefined) ?.result?.items ?? [], ) as Queue[]); const started = Date.now(); const synthetic = generateSyntheticTasks({ count: args.tasks, seed: args.seed, categories, queues, baseSpeedBps: args.speed, }); state.seedSynthetic(synthetic); log(`--tasks ${args.tasks}: generated in ${Date.now() - started} ms ` + `(${synthetic.initiallyActive.length} active now, active-cap ${args.activeCap}, seed ${args.seed})`); } 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(); 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, failed } = state.tick(); // A progress payload only makes sense for a still-active task; a task that just // finished (either direction) gets its own event.task.state below instead, so the two // events never race on the same tick with contradictory numbers. const stillProgressing = moved.filter((t) => t.state === 'downloading'); if (stillProgressing.length > 0) { broadcast('event.task.progress', { tasks: stillProgressing.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', }); } for (const task of failed) { broadcast('event.task.state', { taskId: task.taskId, state: task.state, previousState: 'downloading', summary: task, error: task.error, }); if (task.state === 'failed') { broadcast('event.notify', { level: 'error', title: 'Download failed', body: `${task.filename}: ${task.error?.message ?? 'unknown error'}`, taskId: task.taskId, sound: 'error', }); } } }, 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 };