Generate typed React component bindings

This commit is contained in:
Timothy J. Aveni
2026-07-18 10:33:55 -07:00
parent df4946e016
commit 02e2313aa9
6 changed files with 328 additions and 33 deletions
Generated
+5 -5
View File
@@ -74,17 +74,17 @@
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1784127127,
"narHash": "sha256-M36R+j10krhGRrhCozKkV7PsRM9BklQNqWox2bW7t/0=",
"lastModified": 1784395768,
"narHash": "sha256-g03xOnzJHiL/edNV6nqURtaPqiapq2uCm7wQTZh4b5s=",
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"revCount": 22,
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"revCount": 24,
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
},
"original": {
"ref": "refs/heads/exported",
"rev": "a9134843bc947546c78b28902d081f451733ddba",
"rev": "e6b34535afa6e94a118e1f9d082054855e7df632",
"type": "git",
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
}
+1 -1
View File
@@ -4,7 +4,7 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=a9134843bc947546c78b28902d081f451733ddba";
quixos-protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/heads/exported&rev=e6b34535afa6e94a118e1f9d082054855e7df632";
quixosNixHelpers = {
url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git?ref=refs/heads/exported&rev=1f0c39b01501d646fe97dfc6e5777ccab0bde2f1";
flake = false;
File diff suppressed because one or more lines are too long
+278 -19
View File
@@ -1,7 +1,11 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { compileCaminoSchema } from "./schema-compiler.js";
import protobuf from "protobufjs";
import {
compileCaminoSchema,
loadCaminoSchemaRoot,
} from "./schema-compiler.js";
import type {
ClassSchema,
FieldSchema,
@@ -12,7 +16,7 @@ import type {
const usage = (): never => {
console.error(
"Usage: camino-codegen-ts-runtime <class-schema.camino.proto> <out-dir> [implementation-import]",
"Usage: camino-codegen-ts-runtime [--react-only] <class-schema.camino.proto> <out-dir> [implementation-import]",
);
process.exit(1);
};
@@ -46,11 +50,22 @@ const classId = (schema: ClassSchema) =>
const symbolId = (value: { namespace: string; name: string; version: string; hash?: string }) =>
`${value.namespace}:${value.name}:${value.version}:${value.hash ?? ""}`;
const tsTypeForTypeRef = (type: TypeRef): string => {
const normalizedProtoType = (value: string) => value.replace(/^\./, "");
const declarationName = (value: protobuf.Type | protobuf.Enum) => value.name;
const tsTypeForTypeRef = (
type: TypeRef,
root?: protobuf.Root,
): string => {
if (type.symbol) {
return `ObjectRef<${stringLiteral(symbolId(type.symbol))}>`;
}
if (type.enumValues && type.enumValues.length > 0) {
return type.enumValues.map(stringLiteral).join(" | ");
}
switch (type.protoType.replace(/^\./, "")) {
const protoType = normalizedProtoType(type.protoType);
switch (protoType) {
case "string":
case "google.protobuf.StringValue":
return "string";
@@ -80,19 +95,31 @@ const tsTypeForTypeRef = (type: TypeRef): string => {
case "google.protobuf.BytesValue":
return "Uint8Array";
default:
if (protoType === "google.protobuf.Empty") {
return "Record<string, never>";
}
try {
const reflected = root?.lookup(protoType);
if (reflected instanceof protobuf.Type || reflected instanceof protobuf.Enum) {
return declarationName(reflected);
}
} catch {
// Unknown imported message types remain opaque at this codegen boundary.
}
return "unknown";
}
};
const tsTypeForField = (field: FieldSchema): string => tsTypeForTypeRef(field.type);
const tsTypeForField = (field: FieldSchema, root?: protobuf.Root): string =>
tsTypeForTypeRef(field.type, root);
const tsValueTypeForField = (field: FieldSchema) =>
field.repeated ? `${tsTypeForField(field)}[]` : tsTypeForField(field);
const tsValueTypeForField = (field: FieldSchema, root?: protobuf.Root) =>
field.repeated ? `${tsTypeForField(field, root)}[]` : tsTypeForField(field, root);
const tsFieldApiType = (field: FieldSchema) =>
const tsFieldApiType = (field: FieldSchema, root?: protobuf.Root) =>
field.conflict === "crdt"
? `CrdtField<${tsValueTypeForField(field)}>`
: `Field<${tsValueTypeForField(field)}>`;
? `CrdtField<${tsValueTypeForField(field, root)}>`
: `Field<${tsValueTypeForField(field, root)}>`;
const functionExportName = (fn: FunctionRef, fallback: string) => {
if (isIdentifier(fn.symbol)) {
@@ -107,6 +134,74 @@ const functionExportName = (fn: FunctionRef, fallback: string) => {
const generatedWarning = `// @generated by camino-codegen-ts-runtime. Do not edit.\n`;
const scalarTsType = (field: protobuf.Field, root: protobuf.Root): string => {
const primitive = tsTypeForTypeRef({ protoType: field.type }, root);
if (primitive !== "unknown") {
return primitive;
}
const resolved = field.resolvedType;
if (resolved instanceof protobuf.Type || resolved instanceof protobuf.Enum) {
return declarationName(resolved);
}
return "unknown";
};
const collectProtoDeclarations = (
root: protobuf.Root,
types: TypeRef[],
) => {
const declarations = new Map<string, protobuf.Type | protobuf.Enum>();
const visit = (value: protobuf.Type | protobuf.Enum) => {
const key = value.fullName;
if (declarations.has(key)) {
return;
}
declarations.set(key, value);
if (value instanceof protobuf.Type) {
for (const field of value.fieldsArray) {
if (
field.resolvedType instanceof protobuf.Type ||
field.resolvedType instanceof protobuf.Enum
) {
visit(field.resolvedType);
}
}
}
};
for (const type of types) {
if (type.symbol || !type.protoType) {
continue;
}
try {
const reflected = root.lookup(normalizedProtoType(type.protoType));
if (reflected instanceof protobuf.Type || reflected instanceof protobuf.Enum) {
visit(reflected);
}
} catch {
// Opaque imported types are represented as unknown by the generated API.
}
}
return [...declarations.values()]
.map((value) => {
if (value instanceof protobuf.Enum) {
return `export type ${declarationName(value)} = ${Object.keys(value.values)
.map(stringLiteral)
.join(" | ")};`;
}
const fields = value.fieldsArray.map((field) => {
const fieldType = scalarTsType(field, root);
const valueType = field.map
? `Record<string, ${fieldType}>`
: field.repeated
? `${fieldType}[]`
: fieldType;
return ` ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${valueType};`;
});
return `export type ${declarationName(value)} = {\n${fields.join("\n")}\n};`;
})
.join("\n\n");
};
type FunctionSpec = {
exportName: string;
symbol: string;
@@ -158,10 +253,10 @@ ${bindings}
})
.join("\n\n");
const interfaceMixins = (schema: ClassSchema) =>
const interfaceMixins = (schema: ClassSchema, root: protobuf.Root) =>
schema.implements.map((implementation) => {
const typeArgs = implementation.typeBindings.map((binding) =>
tsTypeForTypeRef(binding.type),
tsTypeForTypeRef(binding.type, root),
);
return `${interfaceTypeName(implementation)}${
typeArgs.length > 0 ? `<${typeArgs.join(", ")}>` : ""
@@ -202,7 +297,7 @@ const operationFunctionSpecs = (schema: ClassSchema): FunctionSpec[] =>
}));
});
const renderRuntime = (schema: ClassSchema) => {
const renderRuntime = (schema: ClassSchema, root: protobuf.Root) => {
const className = schema.id.name;
const objectTypeName = `${className}Object`;
const refTypeName = `${className}Ref`;
@@ -212,7 +307,7 @@ const renderRuntime = (schema: ClassSchema) => {
const createObjectName = `create${className}Object`;
const interfaceRefTypes = renderInterfaceRefTypes(schema);
const refType =
[ `ObjectRef<${stringLiteral(classId(schema))}>`, ...interfaceMixins(schema)]
[ `ObjectRef<${stringLiteral(classId(schema))}>`, ...interfaceMixins(schema, root)]
.join(" & ");
const edgeFields = new Set(schema.edges.map((edge) => edge.sourceField));
const writableFields = schema.fields.filter(
@@ -291,6 +386,8 @@ const renderRuntime = (schema: ClassSchema) => {
serveQuixosPackageRuntime,
} from "@quixos/camino-package-runtime";
${collectProtoDeclarations(root, schema.implements.flatMap((entry) => entry.typeBindings.map((binding) => binding.type)))}
${interfaceRefTypes ? `${interfaceRefTypes}\n\n` : ""}export type ${refTypeName} = ${refType};
export type ${objectTypeName} = {
@@ -298,7 +395,7 @@ export type ${objectTypeName} = {
${writableFields
.map(
(field) =>
` ${camel(field.name)}: ${tsFieldApiType(field)};`,
` ${camel(field.name)}: ${tsFieldApiType(field, root)};`,
)
.join("\n")}
};
@@ -379,6 +476,150 @@ export const ${serveName} = (implementation: ${implementationTypeName}) => {
`;
};
const implementationNamed = (schema: ClassSchema, name: string) =>
schema.implements.find(
(entry) =>
entry.interface.namespace === "quixos.react" &&
entry.interface.name === name,
);
const requiredBinding = (
implementation: InterfaceImplementation,
name: string,
) => {
const binding = implementation.typeBindings.find((entry) => entry.name === name);
if (!binding) {
throw new Error(
`${implementation.interface.name} implementation is missing ${name}`,
);
}
return binding.type;
};
const renderReactClient = (schema: ClassSchema, root: protobuf.Root) => {
const react = implementationNamed(schema, "ReactComponent");
const componentFor = implementationNamed(schema, "ReactComponentFor");
if (!react || !componentFor) {
return undefined;
}
const renderProps = requiredBinding(react, "RenderProps");
const action = requiredBinding(react, "Action");
const object = requiredBinding(componentFor, "Object");
if (!object.symbol) {
throw new Error(`${schema.id.name}.ReactComponentFor.Object must bind a class symbol`);
}
const edge = schema.edges.find((candidate) => candidate.sourceField === "for_object");
if (!edge) {
throw new Error(`${schema.id.name} must declare its ReactComponentFor edge`);
}
const componentEndpoint =
edge.fromEndpoint.projection === "for_object"
? edge.fromEndpoint
: edge.toEndpoint.projection === "for_object"
? edge.toEndpoint
: undefined;
const objectEndpoint =
componentEndpoint === edge.fromEndpoint ? edge.toEndpoint : edge.fromEndpoint;
if (!componentEndpoint || !objectEndpoint.projection) {
throw new Error(`${schema.id.name}.for_object edge has no inverse projection`);
}
if (componentEndpoint.cardinality !== "exactly_one") {
throw new Error(`${schema.id.name}.for_object must have exactly_one cardinality`);
}
if (
!objectEndpoint.materialization ||
symbolId(objectEndpoint.materialization.class) !== symbolId(schema.id)
) {
throw new Error(
`${schema.id.name}.${objectEndpoint.projection} must materialize ${classId(schema)}`,
);
}
const className = schema.id.name;
const componentName = className.endsWith("ReactComponent")
? `${className.slice(0, -"ReactComponent".length)}Component`
: className;
const propsTypeName = `${componentName}Props`;
const renderPropsType = tsTypeForTypeRef(renderProps, root);
const actionType = tsTypeForTypeRef(action, root);
const objectType = tsTypeForTypeRef(object, root);
const declarations = collectProtoDeclarations(root, [renderProps, action]);
return `${generatedWarning}import {
createReactComponentFor,
type ReactComponentHostProps,
type ObjectRef,
} from "@quixos/camino-react-runtime";
${declarations}
export type ${propsTypeName} = {
forObject: ${objectType};
} & ${renderPropsType} & ReactComponentHostProps<${actionType}>;
export const ${componentName} = createReactComponentFor<
${objectType},
${renderPropsType},
${actionType}
>({
componentClassId: ${stringLiteral(classId(schema))},
projection: ${stringLiteral(objectEndpoint.projection)},
});
`;
};
const reactRuntimeDeclaration = `${generatedWarning}declare module "@quixos/camino-react-runtime" {
import type * as AutomergeNamespace from "@automerge/automerge";
export type ObjectRef<ClassId extends string> = string & {
readonly $caminoClass: ClassId;
};
export type LiveFieldProp<T> = {
value: T;
source: {
objectId: string;
fieldName: string;
fieldType?: string;
fieldStorage?: string;
conflictStrategy?: string;
revision?: string | number | bigint;
};
};
export type ReactComponentHostProps<Action> = {
onAction?: (action: Action) => void;
fallback?: unknown;
className?: string;
style?: Record<string, string | number | undefined>;
onError?: (error: Error) => void;
};
export type ReactComponentImplementationProps<CaminoProps, RenderProps, Action> = {
camino: CaminoProps;
render: RenderProps;
dispatch: (action: Action) => void;
};
export const createReactComponentFor: <
ForObject extends ObjectRef<string>,
RenderProps extends object,
Action,
>(config: {
componentClassId: string;
projection: string;
}) => (props: {
forObject: ForObject;
} & RenderProps & ReactComponentHostProps<Action>) => any;
export const h: (...args: any[]) => any;
export const useLiveField: <T>(
field: LiveFieldProp<T>,
) => [T, (value: T) => void | Promise<void>];
export const Automerge: typeof AutomergeNamespace;
}
`;
const renderServer = (schema: ClassSchema, implementationImport: string) => {
const className = schema.id.name;
return `${generatedWarning}import * as implementation from ${stringLiteral(implementationImport)};
@@ -389,10 +630,13 @@ serve${className}Runtime(implementation);
};
const main = async () => {
const sourceFile = required(process.argv[2]);
const outDir = required(process.argv[3]);
const implementationImport = process.argv[4] ?? "../task.impl.js";
const reactOnly = process.argv[2] === "--react-only";
const offset = reactOnly ? 1 : 0;
const sourceFile = required(process.argv[2 + offset]);
const outDir = required(process.argv[3 + offset]);
const implementationImport = process.argv[4 + offset] ?? "../task.impl.js";
const compiled = await compileCaminoSchema(sourceFile);
const { root } = await loadCaminoSchemaRoot(sourceFile);
if (compiled.classes.length !== 1) {
throw new Error(
`Expected exactly one Camino class in ${sourceFile}, found ${compiled.classes.length}`,
@@ -404,11 +648,26 @@ const main = async () => {
}
fs.mkdirSync(outDir, { recursive: true });
const baseName = `${schema.id.namespace}.${schema.id.name}`;
fs.writeFileSync(path.join(outDir, `${baseName}.runtime.ts`), renderRuntime(schema));
const reactClient = renderReactClient(schema, root);
if (!reactOnly) {
fs.writeFileSync(
path.join(outDir, `${baseName}.runtime.ts`),
renderRuntime(schema, root),
);
fs.writeFileSync(
path.join(outDir, `${baseName}.server.ts`),
renderServer(schema, implementationImport),
);
}
if (reactClient) {
fs.writeFileSync(path.join(outDir, `${baseName}.react.ts`), reactClient);
fs.writeFileSync(
path.join(outDir, "camino-react-runtime.d.ts"),
reactRuntimeDeclaration,
);
} else if (reactOnly) {
throw new Error(`${schema.id.name} does not implement ReactComponentFor`);
}
};
await main();
+21 -3
View File
@@ -187,6 +187,11 @@ const interfaceFieldContractFromOption = (
return undefined;
}
const type = typeRefFromOption(object.type, schemaVersion);
const refTargetTypeParam = readString(object, "ref_target_type_param");
const edgeCardinality =
object.edge_cardinality === undefined
? undefined
: cardinalityFromOption(object.edge_cardinality);
return {
required: readBoolean(object, "required") ?? false,
readable: readBoolean(object, "readable") ?? false,
@@ -196,6 +201,8 @@ const interfaceFieldContractFromOption = (
? { typeParam: readString(object, "type_param") }
: {}),
...(type ? { type } : {}),
...(refTargetTypeParam ? { refTargetTypeParam } : {}),
...(edgeCardinality ? { edgeCardinality } : {}),
};
};
@@ -854,6 +861,9 @@ const interfaceSchemaFromType = (
),
sourceFile,
protoMessage: type.fullName,
typeParameters: asArray(interfaceOption.type_parameter)
.map((entry) => String(entry).trim())
.filter(Boolean),
};
};
@@ -884,11 +894,10 @@ const walkServices = (
}
};
export const compileCaminoSchema = async (
export const loadCaminoSchemaRoot = async (
sourceFile: string,
): Promise<SchemaCompileResult> => {
): Promise<{ absoluteSourceFile: string; root: protobuf.Root }> => {
const absoluteSourceFile = path.resolve(sourceFile);
const defaults = extractFileDefaults(absoluteSourceFile);
const root = new protobuf.Root();
const envIncludeDirs = [
process.env.QUIXOS_PROTO_PATH,
@@ -923,6 +932,15 @@ export const compileCaminoSchema = async (
await root.load(absoluteSourceFile, { keepCase: true });
root.resolveAll();
return { absoluteSourceFile, root };
};
export const compileCaminoSchema = async (
sourceFile: string,
): Promise<SchemaCompileResult> => {
const { absoluteSourceFile, root } = await loadCaminoSchemaRoot(sourceFile);
const defaults = extractFileDefaults(absoluteSourceFile);
const operationServices: OperationServiceSchema[] = [];
walkServices(root, (service) => {
operationServices.push(
+3
View File
@@ -69,6 +69,8 @@ export type InterfaceFieldContract = {
watchable: boolean;
typeParam?: string;
type?: TypeRef;
refTargetTypeParam?: string;
edgeCardinality?: Cardinality;
};
export type InterfaceImplementation = {
@@ -162,6 +164,7 @@ export type InterfaceSchema = {
fields: FieldSchema[];
sourceFile: string;
protoMessage: string;
typeParameters: string[];
};
export type SchemaCompileResult = {