import type { BindingSchema } from "./index.js"; import type { ValueType } from "../capability-model/types.js"; /** Browser projection of the SAME checked RPC types, not a second props schema. */ export function generateReactBindings( schema: BindingSchema, packageRevisionId: string, propsExports: readonly string[], components: readonly { module: string; propsExport: string }[] = [], ) { const pkg = schema.packages.find((entry) => entry.revisionId === packageRevisionId); if (!pkg) throw new Error(`Unknown package ${packageRevisionId}`); const q = JSON.stringify; const references = new Map(); const declarations: string[] = []; const type = (value: ValueType): string => { switch (value.kind) { case "variant": throw new Error("Variants require the Camino portable value runtime; legacy RPC bindings are unsupported"); case "builtin": if (value.name !== "unit") throw new Error(`Unsupported React props builtin ${value.name}`); return "null"; case "scalar": return { string: "string", bool: "boolean", bytes: "Uint8Array", int64: "bigint", uint64: "bigint", int32: "number", uint32: "number", double: "number", }[value.name]; case "change": throw new Error("CRDT change values require the prepared-change codec and runtime (LF-19)"); case "optional": return `(${type(value.value)} | null)`; case "list": return `Array<${type(value.value)}>`; case "record": return `{${Object.entries(value.fields) .map(([name, field]) => `${q(name)}: ${type(field)}`) .join(";")}}`; case "message": throw new Error(`React props require checked values, not opaque message ${value.descriptorId}`); case "object-ref": { if (value.expectation.kind === "atom") return `ObjectRef<${q(value.expectation.atomId)}>`; const id = value.expectation.interfaceRevisionId; const existing = references.get(id); if (existing) return existing; const name = `Interface${references.size}`; references.set(id, name); const iface = schema.interfaces.find((entry) => entry.revisionId === id); if (!iface) throw new Error(`Missing React reference contract ${id}`); const fields = iface.members.flatMap((member) => { if (member.kind === "operation") return []; const get = member.operations.find((op) => op.displayName === (member.kind === "value" ? "get" : "resolve")); if (!get) return []; const writable = member.kind === "value" && member.operations.some((op) => op.displayName === "set"); return [`${q(member.displayName)}: ${writable ? "WritableField" : "ReadableField"}<${type(get.outputType)}>`]; }); declarations.push(`export type ${name} = InterfaceReference<${q(id)}, {${fields.join(";")}}> ;`); return name; } } }; // Only exports explicitly selected as props are projected. Non-props exports // may legitimately use opaque messages or types unrelated to the browser. const selected = propsExports.map((name) => { const entry = pkg.exports.find((candidate) => candidate.displayName === name); if (!entry) throw new Error(`Unknown React props export ${name}`); return entry; }); const results = selected.map( (entry) => `${q(entry.displayName)}: ${type(entry.kind === "operation" ? (entry.eventType ?? entry.outputType) : entry.outputType)}`, ); const checks = components.map((component, i) => { if (!propsExports.includes(component.propsExport)) throw new Error(`Component refers to unselected props export ${component.propsExport}`); if (!component.module.startsWith(".")) throw new Error("React component check must name a local module relative to generated bindings"); return `type Component${i} = CheckedComponent<${q(component.propsExport)}, typeof import(${q(component.module)})["default"]>;`; }); // Emit optional discovery descriptors only when the browser has all codecs. // Unrelated opaque-message imports must not break an otherwise checked UI. const browserValue = (value: ValueType, seen: Set): boolean => { if (value.kind === "message") return false; if (value.kind === "builtin") return value.name === "unit"; if (value.kind === "variant") return false; if (value.kind === "record") return Object.values(value.fields).every((field) => browserValue(field, seen)); if (value.kind === "list" || value.kind === "optional") return browserValue(value.value, seen); if (value.kind === "object-ref" && value.expectation.kind === "interface") { const id = value.expectation.interfaceRevisionId; if (seen.has(id)) return true; const contract = schema.interfaces.find((entry) => entry.revisionId === id); return !!contract && browserInterface(contract, new Set([...seen, id])); } return true; }; const browserInterface = (iface: (typeof schema.interfaces)[number], seen: Set): boolean => iface.members.every((member) => member.operations .filter((op) => op.mode === "call" && op.scope !== "class") .every((op) => browserValue(op.inputType, seen) && browserValue(op.outputType, seen)), ); const contracts = schema.interfaces .filter((iface) => browserInterface(iface, new Set([iface.revisionId]))) .map((iface) => { const reference = type({ kind: "object-ref", expectation: { kind: "interface", interfaceRevisionId: iface.revisionId }, }); const calls = iface.members.flatMap((member) => member.operations .filter((operation) => operation.mode === "call" && operation.scope !== "class") .map( (operation) => `${q(`${member.displayName}.${operation.displayName}`)}: (${operation.inputType.kind === "builtin" && operation.inputType.name === "unit" ? "" : `input: ${type(operation.inputType)}`}) => Promise<${type(operation.outputType)}>`, ), ); return { iface, view: `${reference} & {call: {${calls.join(";")}}}` }; }); const queryType = (value: ValueType): string => { if (value.kind === "object-ref") return `QueryReference<${q(value.expectation.kind === "atom" ? value.expectation.atomId : value.expectation.interfaceRevisionId)}>`; if (value.kind === "optional") return `(${queryType(value.value)} | null)`; if (value.kind === "list") return `Array<${queryType(value.value)}>`; if (value.kind === "record") return `{${Object.entries(value.fields) .map(([name, field]) => `${q(name)}: ${queryType(field)}`) .join(";")}}`; return type(value); }; const queries = pkg.checkedQueries ?? []; const queryCode = `export type QueryVariables = {${queries.map((query) => `${q(query.declaration.displayName)}: ${queryType(query.variables)}`).join(";")}};\n` + `export type QueryResults = {${queries.map((query) => `${q(query.declaration.displayName)}: ${queryType(query.output)}`).join(";")}};\n` + `export const queries: {${queries.map((query) => `${q(query.declaration.displayName)}: QueryDescriptor`).join(";")}} = ${JSON.stringify( Object.fromEntries( queries.map((query) => [ query.declaration.displayName, { id: `${pkg.revisionId}:${query.declaration.id}`, definitionDigest: query.definitionDigest, root: query.declaration.root, variables: query.variables, output: query.output, watch: query.declaration.watch, }, ]), ), )};\n`; return ( `// Generated from checked QX contracts. Do not edit.\nimport {defineReactInterfaceContract} from "@quixos/web-studio-react-runtime";\nimport type {ObjectRef, InterfaceReference, ReadableField, WritableField, QueryField, WritableQueryField, QueryDescriptor, QueryReference} from "@quixos/web-studio-react-runtime";\n${declarations.join("\n")}\nexport type ReactResults = {${results.join(";\n")}};\n` + queryCode + `const contractsData = ${JSON.stringify(schema.interfaces)};\n` + `export const reactContracts = {${contracts.map(({ iface, view }) => `${q(iface.revisionId)}: defineReactInterfaceContract<${view}>(${q(iface.revisionId)}, contractsData)`).join(",\n")}};\n` + `export const reactInterfaces = {${contracts .filter(({ iface }) => contracts.filter((other) => other.iface.displayName === iface.displayName).length === 1) .map(({ iface }) => `${q(iface.displayName)}: reactContracts[${q(iface.revisionId)}]`) .join(",\n")}};\n` + (checks.length ? `type CheckedComponent void}) => unknown> = C;\n${checks.join("\n")}\n` : "") ); }