Files
quixos-protocol/src/bindings/react.ts
T

181 lines
9.6 KiB
TypeScript
Raw Normal View History

import type { BindingSchema } from "./index.js";
import type { ValueType } from "../capability-model/types.js";
import { queryPresentation } from "../query/presentation.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<string, string>();
const declarations: string[] = [];
const type = (value: ValueType): string => {
switch (value.kind) {
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 "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<string>): boolean => {
if (value.kind === "message") return false;
if (value.kind === "builtin") return value.name === "unit";
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<string>): 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 presentedType = (
value: ValueType,
fields: ReturnType<typeof queryPresentation>,
path: string[] = [],
): string => {
const field = fields.find((field) => JSON.stringify(field.path) === JSON.stringify(path));
if (field)
return `${field.conditions.length ? "(" : ""}${field.setOperationId ? "WritableField" : "ReadableField"}<${type(field.valueType)}>${field.conditions.length ? " | null)" : ""}`;
if (value.kind === "optional") return `(${presentedType(value.value, fields, path)} | null)`;
if (value.kind === "list") return `Array<${presentedType(value.value, fields, [...path, "*"])}>`;
if (value.kind === "record")
return `{${Object.entries(value.fields)
.map(([key, child]) => `${q(key)}: ${presentedType(child, fields, [...path, key])}`)
.join(";")}}`;
return queryType(value);
};
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)}: ${presentedType(query.output, queryPresentation(query, schema.interfaces))}`).join(";")}};\n` +
`export const queries: {${queries.map((query) => `${q(query.declaration.displayName)}: QueryDescriptor<QueryVariables[${q(query.declaration.displayName)}], QueryResults[${q(query.declaration.displayName)}]>`).join(";")}} = ${JSON.stringify(
Object.fromEntries(
queries.map((query) => [
query.declaration.displayName,
{
id: `${pkg.revisionId}:${query.declaration.id}`,
definitionDigest: query.definitionDigest,
rootInterfaceRevisionId: query.declaration.root,
variables: query.variables,
output: query.output,
liveFields: queryPresentation(query, schema.interfaces),
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, 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<K extends keyof ReactResults, C extends (props: {camino: ReactResults[K]; render: any; dispatch: (action: any) => void}) => unknown> = C;\n${checks.join("\n")}\n`
: "")
);
}