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
620 lines
24 KiB
Python
620 lines
24 KiB
Python
#!/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<M>()` 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<string, {ts_type(ref.inner)}>"
|
|
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<string, never>;")
|
|
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<string, unknown> | null;",
|
|
"}",
|
|
"",
|
|
"/** A response is one or the other, never both — narrow on `error`. */",
|
|
"export type RpcResponse<T> =",
|
|
" | { 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<M extends MethodName> = MethodMap[M]['params'];",
|
|
"export type Result<M extends MethodName> = 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<M extends MethodName>(method: M, params: Params<M>): Promise<Result<M>>;",
|
|
"}",
|
|
""]
|
|
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<E extends EventName> = 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<T> =
|
|
| { ok: true; value: T }
|
|
| { ok: false; path: string; message: string };
|
|
|
|
export type Validator<T> = (v: unknown, path: string) => Validated<T>;
|
|
|
|
function fail(path: string, message: string): Validated<never> {
|
|
return { ok: false, path, message };
|
|
}
|
|
|
|
function join(path: string, key: string): string {
|
|
return path ? `${path}/${key}` : `/${key}`;
|
|
}
|
|
|
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
}
|
|
|
|
export const vString: Validator<string> = (v, p) =>
|
|
typeof v === 'string' ? { ok: true, value: v } : fail(p, 'expected a string');
|
|
|
|
export const vNumber: Validator<number> = (v, p) =>
|
|
typeof v === 'number' && Number.isFinite(v) ? { ok: true, value: v } : fail(p, 'expected a number');
|
|
|
|
export const vInteger: Validator<number> = (v, p) =>
|
|
typeof v === 'number' && Number.isInteger(v) ? { ok: true, value: v } : fail(p, 'expected an integer');
|
|
|
|
export const vBoolean: Validator<boolean> = (v, p) =>
|
|
typeof v === 'boolean' ? { ok: true, value: v } : fail(p, 'expected a boolean');
|
|
|
|
export const vUnknown: Validator<unknown> = (v) => ({ ok: true, value: v });
|
|
|
|
function vArray<T>(inner: Validator<T>): Validator<T[]> {
|
|
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<T>(inner: Validator<T>): Validator<Record<string, T>> {
|
|
return (v, p) => {
|
|
if (!isPlainObject(v)) return fail(p, 'expected an object');
|
|
const out: Record<string, T> = {};
|
|
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<T>(inner: Validator<T>, limits: Limits): Validator<T> {
|
|
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<T extends string>(values: readonly T[], name: string): Validator<T> {
|
|
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<T extends number>(values: readonly T[], name: string): Validator<T> {
|
|
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<T>(
|
|
obj: Record<string, unknown>,
|
|
key: string,
|
|
path: string,
|
|
inner: Validator<T>,
|
|
out: Record<string, unknown>,
|
|
): Validated<null> {
|
|
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<T>(
|
|
obj: Record<string, unknown>,
|
|
key: string,
|
|
path: string,
|
|
inner: Validator<T>,
|
|
out: Record<string, unknown>,
|
|
): Validated<null> {
|
|
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<string, unknown> = {};")
|
|
o.append(" let r: Validated<null>;")
|
|
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<MethodMap[M]['params']> } = {"]
|
|
for m in c.methods:
|
|
o.append(f" {method_key(m.name)}: {validator_expr(m.params)},")
|
|
o += ["};", "",
|
|
"const RESULT_VALIDATORS: { [M in MethodName]: Validator<MethodMap[M]['result']> } = {"]
|
|
for m in c.methods:
|
|
o.append(f" {method_key(m.name)}: {validator_expr(m.result)},")
|
|
o += ["};", "",
|
|
"const EVENT_VALIDATORS: { [E in EventName]: Validator<EventMap[E]> } = {"]
|
|
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<M extends MethodName>(method: M, v: unknown): Validated<MethodMap[M]['params']> {",
|
|
" return PARAMS_VALIDATORS[method](v, 'params');",
|
|
"}",
|
|
"",
|
|
"/** Validate a result the client just received for `method`. */",
|
|
"export function validateResult<M extends MethodName>(method: M, v: unknown): Validated<MethodMap[M]['result']> {",
|
|
" return RESULT_VALIDATORS[method](v, 'result');",
|
|
"}",
|
|
"",
|
|
"/** Validate a notification payload. */",
|
|
"export function validateEventParams<E extends EventName>(event: E, v: unknown): Validated<EventMap[E]> {",
|
|
" 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())
|