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,619 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user