#!/usr/bin/env python3 """Emit extension/src/shared/protocol/ from contracts/schema/. What the EXT and GUI-adjacent lanes get: * `types.ts` — every contract type as a TS interface or string-literal union. * `methods.ts` — the `MethodMap`, a typed `call()` signature, and per-method metadata (privileged, transports, deadlineMs). capture.offer's 750 ms budget is a generated constant, not a number typed twice. * `events.ts` — event payload types and a discriminated union of notifications. * `validate.ts` — runtime validators for everything crossing the wire. * `index.ts` — the public surface. The validators exist because **the extension is not allowed to trust the daemon and the daemon is not allowed to trust the extension.** A `ws://127.0.0.1` socket is reachable by any local process, so a TypeScript type alone proves nothing at runtime. Run: python3 contracts/codegen/gen_ts.py """ from __future__ import annotations import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from schema_ir import Contract, Field, TypeDef, TypeRef, load, pascal, topo_sorted # noqa: E402 OUT_DIR = Path(__file__).resolve().parent.parent.parent / "extension" / "src" / "shared" / "protocol" BANNER = """// --------------------------------------------------------------------------- // GENERATED FILE — DO NOT EDIT. // // Source: contracts/schema/** // Generator: contracts/codegen/gen_ts.py // Contract: v{version} // // Hand-editing this file is a merge blocker. Fix the schema and regenerate: // python3 contracts/codegen/gen_ts.py // Only lane PROTO commits to contracts/. // --------------------------------------------------------------------------- """ IDENT_OK = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$") def prop_key(name: str) -> str: """Property names such as `general.launchOnLogin` must be quoted.""" if name and name[0].isalpha() and all(ch in IDENT_OK for ch in name): return name return json.dumps(name) def ts_type(ref: TypeRef) -> str: if ref.kind == "named": return ref.name or "never" if ref.kind == "string": return "string" if ref.kind in ("integer", "number"): return "number" if ref.kind == "boolean": return "boolean" if ref.kind == "json": return "unknown" if ref.kind == "array": inner = ts_type(ref.inner) return f"Array<{inner}>" if not inner.isidentifier() else f"{inner}[]" if ref.kind == "map": return f"Record" raise AssertionError(ref.kind) def doc_block(text: str, indent: str = "") -> list[str]: if not text: return [] words, lines, cur = text.split(), [], "" for w in words: if len(cur) + len(w) + 1 > 88: lines.append(cur) cur = w else: cur = f"{cur} {w}".strip() if cur: lines.append(cur) if len(lines) == 1: return [f"{indent}/** {lines[0]} */"] return [f"{indent}/**"] + [f"{indent} * {ln}" for ln in lines] + [f"{indent} */"] def event_ident(name: str) -> str: return pascal(name[len("event."):]) + "Event" def method_key(name: str) -> str: return json.dumps(name) # ------------------------------------------------------------------- types.ts def emit_types(c: Contract) -> str: o = [BANNER.format(version=c.version), ""] o.append(f'export const PROTOCOL_VERSION = {json.dumps(c.version)};') o.append("") for t in topo_sorted(c.types): if t.kind == "string_enum": o += doc_block(t.doc) union = " | ".join(json.dumps(v.wire) for v in t.values) o.append(f"export type {t.name} = {union};") o.append(f"export const {upper_snake(t.name)}_VALUES = [") for v in t.values: o.append(f" {json.dumps(v.wire)},") o.append(f"] as const satisfies readonly {t.name}[];") o.append("") elif t.kind == "int_enum": o += doc_block(t.doc) o.append(f"export const {t.name} = {{") for v in t.values: if v.doc: o += doc_block(v.doc, " ") o.append(f" {v.name}: {v.wire},") o.append("} as const;") o.append(f"export type {t.name} = (typeof {t.name})[keyof typeof {t.name}];") o.append("") elif t.kind == "map_alias": o += doc_block(t.doc) o.append(f"export type {t.name} = {ts_type(t.alias)};") o.append("") elif t.kind == "struct": o += doc_block(t.doc) if not t.fields: o.append(f"/** No parameters. */") o.append(f"export type {t.name} = Record;") o.append("") continue o.append(f"export interface {t.name} {{") for f in t.fields: o += doc_block(f.doc, " ") opt = "?" if not f.required else "" null = " | null" if f.nullable else "" o.append(f" {prop_key(f.name)}{opt}: {ts_type(f.type)}{null};") o.append("}") o.append("") o += [ "/** JSON-RPC error as it appears on the wire. */", "export interface RpcError {", " code: ErrorCode;", " message: string;", " data?: Record | null;", "}", "", "/** A response is one or the other, never both — narrow on `error`. */", "export type RpcResponse =", " | { jsonrpc: '2.0'; id: number | string; result: T; error?: undefined }", " | { jsonrpc: '2.0'; id: number | string; result?: undefined; error: RpcError };", "", ] return "\n".join(o) def upper_snake(name: str) -> str: out = [] for i, ch in enumerate(name): if ch.isupper() and i and not name[i - 1].isupper(): out.append("_") out.append(ch.upper()) return "".join(out) # ----------------------------------------------------------------- methods.ts def emit_methods(c: Contract) -> str: imports = sorted({r.name for m in c.methods for r in (m.params, m.result) if r.kind == "named"}) o = [BANNER.format(version=c.version), ""] o.append("import type {") for name in imports: o.append(f" {name},") o.append("} from './types.js';") o.append("") o += [ "/** Params and result for every method, keyed by its wire name. */", "export interface MethodMap {", ] for m in c.methods: o += doc_block(m.doc, " ") o.append(f" {method_key(m.name)}: {{ params: {ts_type(m.params)}; result: {ts_type(m.result)} }};") o += ["}", "", "export type MethodName = keyof MethodMap;", "export type Params = MethodMap[M]['params'];", "export type Result = MethodMap[M]['result'];", "", "export type Transport = 'uds' | 'ws';", "", "export interface MethodMeta {", " /** Refused over the WebSocket transport with -32003. */", " readonly privileged: boolean;", " readonly transports: readonly Transport[];", " /** How long a client waits before giving up on this call. */", " readonly deadlineMs: number;", " /** Error codes this method is documented to return. */", " readonly errors: readonly number[];", "}", "", "export const METHODS: { readonly [M in MethodName]: MethodMeta } = {"] for m in c.methods: transports = ", ".join(f"'{t}'" for t in m.transports) errors = ", ".join(str(e) for e in m.errors) o.append(f" {method_key(m.name)}: {{ privileged: {str(m.privileged).lower()}, " f"transports: [{transports}], deadlineMs: {m.deadline_ms}, errors: [{errors}] }},") o += ["} as const;", "", "export const METHOD_NAMES = Object.keys(METHODS) as MethodName[];", "", "export function isMethodName(v: unknown): v is MethodName {", " return typeof v === 'string' && Object.prototype.hasOwnProperty.call(METHODS, v);", "}", "", "/** Methods this transport may call. The extension checks before sending so a", " * privileged call fails in one place rather than as a puzzling -32003. */", "export function isAllowedOn(method: MethodName, transport: Transport): boolean {", " return (METHODS[method].transports as readonly string[]).includes(transport);", "}", "", "/**", " * The typed client surface. Every transport implements this; the generated", " * signature is what stops a caller passing download.add's params to download.get.", " */", "export interface VeloxClient {", " call(method: M, params: Params): Promise>;", "}", ""] return "\n".join(o) # ------------------------------------------------------------------ events.ts def emit_events(c: Contract) -> str: imports = sorted({e.params.name for e in c.events if e.params.kind == "named"}) o = [BANNER.format(version=c.version), ""] o.append("import type {") for name in imports: o.append(f" {name},") o.append("} from './types.js';") o.append("") o.append("/** Payload for each server-to-client notification, keyed by its wire name. */") o.append("export interface EventMap {") for e in c.events: o += doc_block(e.doc, " ") o.append(f" {method_key(e.name)}: {ts_type(e.params)};") o += ["}", "", "export type EventName = keyof EventMap;", "export type EventPayload = EventMap[E];", "", "/**", " * Discriminated on `method`: narrowing an incoming notification gives the", " * correctly typed params with no cast at the call site.", " */", "export type ServerNotification = {", " [E in EventName]: { jsonrpc: '2.0'; method: E; params: EventMap[E] };", "}[EventName];", "", "export interface EventMeta {", " /** Upper bound on emission rate, where the contract sets one. */", " readonly maxRateHz: number | null;", "}", "", "export const EVENTS: { readonly [E in EventName]: EventMeta } = {"] for e in c.events: rate = "null" if e.max_rate_hz is None else str(e.max_rate_hz) o.append(f" {method_key(e.name)}: {{ maxRateHz: {rate} }},") o += ["} as const;", "", "export const EVENT_NAMES = Object.keys(EVENTS) as EventName[];", "", "export function isEventName(v: unknown): v is EventName {", " return typeof v === 'string' && Object.prototype.hasOwnProperty.call(EVENTS, v);", "}", ""] return "\n".join(o) # ---------------------------------------------------------------- validate.ts PRELUDE = """ /** * Runtime validation for everything that crosses the wire. * * The daemon does not trust the extension and the extension does not trust the daemon: * `ws://127.0.0.1` is reachable by any local process, so a TypeScript type proves nothing * at runtime. Every inbound payload goes through one of these before it is used. * * Validators mirror the C++ side exactly, including the rule that an absent field and an * explicit null mean the same thing. */ export type Validated = | { ok: true; value: T } | { ok: false; path: string; message: string }; export type Validator = (v: unknown, path: string) => Validated; function fail(path: string, message: string): Validated { return { ok: false, path, message }; } function join(path: string, key: string): string { return path ? `${path}/${key}` : `/${key}`; } function isPlainObject(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } export const vString: Validator = (v, p) => typeof v === 'string' ? { ok: true, value: v } : fail(p, 'expected a string'); export const vNumber: Validator = (v, p) => typeof v === 'number' && Number.isFinite(v) ? { ok: true, value: v } : fail(p, 'expected a number'); export const vInteger: Validator = (v, p) => typeof v === 'number' && Number.isInteger(v) ? { ok: true, value: v } : fail(p, 'expected an integer'); export const vBoolean: Validator = (v, p) => typeof v === 'boolean' ? { ok: true, value: v } : fail(p, 'expected a boolean'); export const vUnknown: Validator = (v) => ({ ok: true, value: v }); function vArray(inner: Validator): Validator { return (v, p) => { if (!Array.isArray(v)) return fail(p, 'expected an array'); const out: T[] = []; for (let i = 0; i < v.length; i += 1) { const r = inner(v[i], join(p, String(i))); if (!r.ok) return r; out.push(r.value); } return { ok: true, value: out }; }; } function vRecord(inner: Validator): Validator> { return (v, p) => { if (!isPlainObject(v)) return fail(p, 'expected an object'); const out: Record = {}; for (const [k, raw] of Object.entries(v)) { const r = inner(raw, join(p, k)); if (!r.ok) return r; out[k] = r.value; } return { ok: true, value: out }; }; } /** * Range, length and pattern checks. The wire is untrusted, so a `maximum` in the schema * has to be a check at runtime — a TypeScript type cannot enforce one. */ interface Limits { readonly minimum?: number; readonly maximum?: number; readonly minLength?: number; readonly maxLength?: number; readonly pattern?: RegExp; readonly minItems?: number; readonly maxItems?: number; } function vLimited(inner: Validator, limits: Limits): Validator { return (v, p) => { const r = inner(v, p); if (!r.ok) return r; const value = r.value; if (typeof value === 'number') { if (limits.minimum !== undefined && value < limits.minimum) return fail(p, `value is below the minimum of ${limits.minimum}`); if (limits.maximum !== undefined && value > limits.maximum) return fail(p, `value is above the maximum of ${limits.maximum}`); } else if (typeof value === 'string') { if (limits.minLength !== undefined && value.length < limits.minLength) return fail(p, `value is shorter than ${limits.minLength} characters`); if (limits.maxLength !== undefined && value.length > limits.maxLength) return fail(p, `value is longer than ${limits.maxLength} characters`); if (limits.pattern !== undefined && !limits.pattern.test(value)) return fail(p, 'value does not match the required pattern'); } else if (Array.isArray(value)) { if (limits.minItems !== undefined && value.length < limits.minItems) return fail(p, `fewer than ${limits.minItems} items`); if (limits.maxItems !== undefined && value.length > limits.maxItems) return fail(p, `more than ${limits.maxItems} items`); } return r; }; } function vEnum(values: readonly T[], name: string): Validator { return (v, p) => typeof v === 'string' && (values as readonly string[]).includes(v) ? { ok: true, value: v as T } : fail(p, `not a valid ${name}`); } function vIntEnum(values: readonly T[], name: string): Validator { return (v, p) => typeof v === 'number' && (values as readonly number[]).includes(v) ? { ok: true, value: v as T } : fail(p, `not a valid ${name}`); } /** Required: must be present and non-null. */ function req( obj: Record, key: string, path: string, inner: Validator, out: Record, ): Validated { const raw = obj[key]; if (raw === undefined || raw === null) return fail(join(path, key), 'required field is missing'); const r = inner(raw, join(path, key)); if (!r.ok) return r; out[key] = r.value; return { ok: true, value: null }; } /** Optional: absent and null are the same thing, exactly as on the C++ side. */ function opt( obj: Record, key: string, path: string, inner: Validator, out: Record, ): Validated { const raw = obj[key]; if (raw === undefined || raw === null) return { ok: true, value: null }; const r = inner(raw, join(path, key)); if (!r.ok) return r; out[key] = r.value; return { ok: true, value: null }; } """ def ts_limits(ref: TypeRef) -> str: """The Limits object literal for a TypeRef, or "" when it is unconstrained.""" lim = ref.limits parts = [] for key in ("minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"): if key in lim: parts.append(f"{key}: {lim[key]}") if "pattern" in lim: parts.append("pattern: " + js_regex(str(lim["pattern"]))) return "{ " + ", ".join(parts) + " }" if parts else "" def js_regex(pattern: str) -> str: return "/" + pattern.replace("/", "\\/") + "/" def validator_expr(ref: TypeRef) -> str: base = _validator_base(ref) limits = ts_limits(ref) return f"vLimited({base}, {limits})" if limits else base def _validator_base(ref: TypeRef) -> str: if ref.kind == "named": return f"validate{ref.name}" if ref.kind == "string": return "vString" if ref.kind == "integer": return "vInteger" if ref.kind == "number": return "vNumber" if ref.kind == "boolean": return "vBoolean" if ref.kind == "json": return "vUnknown" if ref.kind == "array": return f"vArray({validator_expr(ref.inner)})" if ref.kind == "map": return f"vRecord({validator_expr(ref.inner)})" raise AssertionError(ref.kind) def emit_validate(c: Contract) -> str: # An int enum is exported from types.ts as a const *and* a type under one name, so a # value import already brings the type with it. Importing it twice is a TS2300. value_imported = {t.name for t in c.types if t.kind == "int_enum"} type_names = [t.name for t in c.types if t.name not in value_imported] o = [BANNER.format(version=c.version), ""] o.append("import type {") for name in sorted(type_names): o.append(f" {name},") o.append("} from './types.js';") o.append("import {") for t in sorted(c.types, key=lambda t: t.name): if t.kind == "string_enum": o.append(f" {upper_snake(t.name)}_VALUES,") elif t.kind == "int_enum": o.append(f" {t.name},") o.append("} from './types.js';") o.append("import { isEventName, type EventMap, type EventName } from './events.js';") o.append("import { isMethodName, type MethodMap, type MethodName } from './methods.js';") o.append(PRELUDE) for t in c.types: if t.kind == "string_enum": o.append(f"export const validate{t.name}: Validator<{t.name}> = " f"vEnum({upper_snake(t.name)}_VALUES, '{t.name}');") o.append("") elif t.kind == "int_enum": o.append(f"const {upper_snake(t.name)}_VALUES = Object.values({t.name}) as {t.name}[];") o.append(f"export const validate{t.name}: Validator<{t.name}> = " f"vIntEnum({upper_snake(t.name)}_VALUES, '{t.name}');") o.append("") elif t.kind == "map_alias": o.append(f"export const validate{t.name}: Validator<{t.name}> = " f"{validator_expr(t.alias)};") o.append("") elif t.kind == "struct": o += doc_block(f"Validate an untrusted value as {t.name}.") o.append(f"export function validate{t.name}(v: unknown, path = ''): Validated<{t.name}> {{") o.append(" if (!isPlainObject(v)) return fail(path, 'expected an object');") if not t.fields: o.append(f" return {{ ok: true, value: {{}} as {t.name} }};") o.append("}") o.append("") continue o.append(" const out: Record = {};") o.append(" let r: Validated;") for f in t.fields: fn = "opt" if f.optional else "req" o.append(f" r = {fn}(v, {json.dumps(f.name)}, path, {validator_expr(f.type)}, out);") o.append(" if (!r.ok) return r;") o.append(f" return {{ ok: true, value: out as unknown as {t.name} }};") o.append("}") o.append("") # dispatch tables o += ["// --- by-name entry points --------------------------------------------------", "", "const PARAMS_VALIDATORS: { [M in MethodName]: Validator } = {"] for m in c.methods: o.append(f" {method_key(m.name)}: {validator_expr(m.params)},") o += ["};", "", "const RESULT_VALIDATORS: { [M in MethodName]: Validator } = {"] for m in c.methods: o.append(f" {method_key(m.name)}: {validator_expr(m.result)},") o += ["};", "", "const EVENT_VALIDATORS: { [E in EventName]: Validator } = {"] for e in c.events: o.append(f" {method_key(e.name)}: {validator_expr(e.params)},") o += ["};", "", "/** Validate params the daemon is about to receive for `method`. */", "export function validateParams(method: M, v: unknown): Validated {", " return PARAMS_VALIDATORS[method](v, 'params');", "}", "", "/** Validate a result the client just received for `method`. */", "export function validateResult(method: M, v: unknown): Validated {", " return RESULT_VALIDATORS[method](v, 'result');", "}", "", "/** Validate a notification payload. */", "export function validateEventParams(event: E, v: unknown): Validated {", " return EVENT_VALIDATORS[event](v, 'params');", "}", "", "/**", " * Validate a whole inbound notification frame, including its method name.", " * Anything unrecognised is rejected rather than passed on: an unknown method on a", " * loopback socket is either a version skew or another local process probing us.", " */", "export function validateNotification(", " frame: unknown,", "): Validated<{ method: EventName; params: EventMap[EventName] }> {", " if (!isPlainObject(frame)) return fail('', 'expected an object');", " if (frame['jsonrpc'] !== '2.0') return fail('/jsonrpc', \"expected '2.0'\");", " const method = frame['method'];", " if (!isEventName(method)) return fail('/method', 'unknown event');", " const params = validateEventParams(method, frame['params']);", " if (!params.ok) return params;", " return { ok: true, value: { method, params: params.value } };", "}", "", "export { isEventName, isMethodName };", ""] return "\n".join(o) def emit_index(c: Contract) -> str: return "\n".join([ BANNER.format(version=c.version), "", "export * from './types.js';", "export * from './methods.js';", "export * from './events.js';", "export * from './validate.js';", "", ]) def main() -> int: c = load() OUT_DIR.mkdir(parents=True, exist_ok=True) (OUT_DIR / "types.ts").write_text(emit_types(c)) (OUT_DIR / "methods.ts").write_text(emit_methods(c)) (OUT_DIR / "events.ts").write_text(emit_events(c)) (OUT_DIR / "validate.ts").write_text(emit_validate(c)) (OUT_DIR / "index.ts").write_text(emit_index(c)) print(f"gen_ts: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events -> {OUT_DIR}") return 0 if __name__ == "__main__": raise SystemExit(main())