#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import protobuf from "protobufjs"; import { compileCaminoSchema, loadCaminoSchemaRoot, } from "./schema-compiler.js"; const usage = () => { console.error("Usage: camino-codegen-ts-runtime [--react-only] [implementation-import]"); process.exit(1); }; const required = (value) => value ?? usage(); const stringLiteral = (value) => JSON.stringify(value); const upperFirst = (value) => value.length === 0 ? value : `${value[0]?.toUpperCase()}${value.slice(1)}`; const lowerFirst = (value) => value.length === 0 ? value : `${value[0]?.toLowerCase()}${value.slice(1)}`; const camel = (value) => value .split(/[^a-zA-Z0-9]+/) .filter(Boolean) .map((part, index) => index === 0 ? lowerFirst(part) : upperFirst(lowerFirst(part))) .join(""); const isIdentifier = (value) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value); const classId = (schema) => `${schema.id.namespace}:${schema.id.name}:${schema.id.version}:${schema.id.hash ?? ""}`; const symbolId = (value) => `${value.namespace}:${value.name}:${value.version}:${value.hash ?? ""}`; const normalizedProtoType = (value) => value.replace(/^\./, ""); const declarationName = (value) => value.name; const tsTypeForTypeRef = (type, root) => { if (type.symbol) { return `ObjectRef<${stringLiteral(symbolId(type.symbol))}>`; } if (type.enumValues && type.enumValues.length > 0) { return type.enumValues.map(stringLiteral).join(" | "); } const protoType = normalizedProtoType(type.protoType); switch (protoType) { case "string": case "google.protobuf.StringValue": return "string"; case "bool": case "google.protobuf.BoolValue": return "boolean"; case "double": case "float": case "google.protobuf.DoubleValue": case "google.protobuf.FloatValue": case "int32": case "sint32": case "sfixed32": case "uint32": case "fixed32": case "int64": case "sint64": case "sfixed64": case "uint64": case "fixed64": case "google.protobuf.Int32Value": case "google.protobuf.UInt32Value": case "google.protobuf.Int64Value": case "google.protobuf.UInt64Value": return "number"; case "bytes": case "google.protobuf.BytesValue": return "Uint8Array"; default: if (protoType === "google.protobuf.Empty") { return "Record"; } 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, root) => tsTypeForTypeRef(field.type, root); const tsValueTypeForField = (field, root) => field.repeated ? `${tsTypeForField(field, root)}[]` : tsTypeForField(field, root); const tsFieldApiType = (field, root) => field.conflict === "crdt" ? `CrdtField<${tsValueTypeForField(field, root)}>` : `Field<${tsValueTypeForField(field, root)}>`; const functionExportName = (fn, fallback) => { if (isIdentifier(fn.symbol)) { return fn.symbol; } const fallbackName = camel(fallback); if (isIdentifier(fallbackName)) { return fallbackName; } throw new Error(`Cannot generate TS export name for function ${fn.symbol}`); }; const generatedWarning = `// @generated by camino-codegen-ts-runtime. Do not edit.\n`; const scalarTsType = (field, root) => { 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, types) => { const declarations = new Map(); const visit = (value) => { 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` : 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"); }; const fieldTypeId = (field) => field.type.symbol ? symbolId(field.type.symbol) : field.type.protoType; const crdtStorageTypeId = (field) => field.type.protoType || (field.type.symbol ? `${field.type.symbol.namespace}.${field.type.symbol.name}` : fieldTypeId(field)); const fieldStorageId = (field) => field.ops && field.storage.kind === "stored" ? "derived" : field.storage.kind; const interfaceTypeName = (implementation) => `${implementation.interface.name}Ref`; const renderInterfaceRefTypes = (schema) => schema.implements .map((implementation) => { const typeParams = implementation.typeBindings.map((binding) => binding.name); const generic = typeParams.length > 0 ? `<${typeParams.join(", ")}>` : ""; const bindings = typeParams.length > 0 ? typeParams .map((param) => ` readonly ${param}: ${param};`) .join("\n") : " readonly value: unknown;"; return `export type ${interfaceTypeName(implementation)}${generic} = ObjectRef & { readonly $caminoInterfaces: { readonly ${stringLiteral(symbolId(implementation.interface))}: { ${bindings} }; }; };`; }) .join("\n\n"); const interfaceMixins = (schema, root) => schema.implements.map((implementation) => { const typeArgs = implementation.typeBindings.map((binding) => tsTypeForTypeRef(binding.type, root)); return `${interfaceTypeName(implementation)}${typeArgs.length > 0 ? `<${typeArgs.join(", ")}>` : ""}`; }); const operationFunctionSpecs = (schema) => schema.fields.flatMap((field) => { const serviceName = field.ops?.service.replace(/^\./, ""); if (!serviceName) { return []; } const service = schema.operationServices.find((candidate) => candidate.fullName === serviceName); if (!service) { throw new Error(`${schema.id.name}.${field.name} points to missing operation service ${field.ops?.service}`); } return service.operations.map((operation) => ({ exportName: functionExportName(operation.function, `${field.name}_${operation.name}`), symbol: operation.function.symbol, ...(operation.function.operation ? { operation: operation.function.operation } : {}), packageNamespace: operation.function.packageNamespace, packageName: operation.function.packageName, field: { name: field.name, type: fieldTypeId(field), storage: fieldStorageId(field), conflict: field.conflict, }, })); }); const renderRuntime = (schema, root) => { const className = schema.id.name; const objectTypeName = `${className}Object`; const refTypeName = `${className}Ref`; const contextTypeName = `${className}Context`; const implementationTypeName = `${className}Implementation`; const serveName = `serve${className}Runtime`; const createObjectName = `create${className}Object`; const interfaceRefTypes = renderInterfaceRefTypes(schema); const refType = [`ObjectRef<${stringLiteral(classId(schema))}>`, ...interfaceMixins(schema, root)] .join(" & "); const edgeFields = new Set(schema.edges.map((edge) => edge.sourceField)); const writableFields = schema.fields.filter((field) => field.storage.kind === "stored" && !field.ops && !edgeFields.has(field.name)); const functionSpecs = [ ...(schema.constructorSpec ? [ { exportName: functionExportName(schema.constructorSpec.function, "constructor"), symbol: schema.constructorSpec.function.symbol, ...(schema.constructorSpec.function.operation ? { operation: schema.constructorSpec.function.operation } : {}), packageNamespace: schema.constructorSpec.function.packageNamespace, packageName: schema.constructorSpec.function.packageName, }, ] : []), ...schema.methods.map((method) => ({ exportName: functionExportName(method.function, method.name), symbol: method.function.symbol, ...(method.function.operation ? { operation: method.function.operation } : {}), packageNamespace: method.function.packageNamespace, packageName: method.function.packageName, })), ...schema.fields.flatMap((field) => field.storage.resolver ? [ { exportName: functionExportName(field.storage.resolver, field.name), symbol: field.storage.resolver.symbol, ...(field.storage.resolver.operation ? { operation: field.storage.resolver.operation } : {}), packageNamespace: field.storage.resolver.packageNamespace, packageName: field.storage.resolver.packageName, }, ] : []), ...operationFunctionSpecs(schema), ]; const firstFunction = functionSpecs[0]; if (!firstFunction) { throw new Error(`${schema.id.name} has no runtime functions to generate`); } const mixedPackage = functionSpecs.find((spec) => spec.packageNamespace !== firstFunction.packageNamespace || spec.packageName !== firstFunction.packageName); if (mixedPackage) { throw new Error(`${schema.id.name} points to multiple packages; v0 TS runtime generation expects one package`); } return `${generatedWarning}import { type CaminoClient, createCrdtField, createField, type CrdtField, type Field, type FieldOperation, type InvokeRequest, objectRef, type ObjectRef, type RuntimeHandler, 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} = { id: ${refTypeName}; ${writableFields .map((field) => ` ${camel(field.name)}: ${tsFieldApiType(field, root)};`) .join("\n")} }; export type ${contextTypeName} = { object: ${objectTypeName}; camino: CaminoClient; request: InvokeRequest; }; export type ${implementationTypeName} = { ${functionSpecs .map((spec) => spec.operation ? ` ${spec.exportName}: FieldOperation<${contextTypeName}>;` : ` ${spec.exportName}: RuntimeHandler<${contextTypeName}>;`) .filter((line, index, lines) => lines.indexOf(line) === index) .join("\n")} }; const packageNamespace = ${stringLiteral(firstFunction.packageNamespace)}; const packageName = ${stringLiteral(firstFunction.packageName)}; const runtimeProtocolVersion = ${stringLiteral("camino-orch-v0")}; const functions = [ ${functionSpecs .map((spec) => ` { exportName: ${stringLiteral(spec.exportName)}, symbol: ${stringLiteral(spec.symbol)}${spec.operation ? `, operation: ${stringLiteral(spec.operation)}` : ""}${spec.field ? `, field: { name: ${stringLiteral(spec.field.name)}, type: ${stringLiteral(spec.field.type)}, storage: ${stringLiteral(spec.field.storage)}, conflict: ${stringLiteral(spec.field.conflict)} }` : ""} },`) .join("\n")} ] as const; export const ${createObjectName} = ( camino: CaminoClient, objectId: string, ): ${objectTypeName} => { const id = objectRef<${stringLiteral(classId(schema))}>(objectId) as ${refTypeName}; return { id, ${writableFields .map((fieldSchema) => fieldSchema.conflict === "crdt" ? ` ${camel(fieldSchema.name)}: createCrdtField(camino, id, ${stringLiteral(fieldSchema.name)}, ${stringLiteral(crdtStorageTypeId(fieldSchema))}),` : ` ${camel(fieldSchema.name)}: createField(camino, id, ${stringLiteral(fieldSchema.name)}),`) .join("\n")} }; }; export const create${className}Context = ( camino: CaminoClient, request: InvokeRequest, ): ${contextTypeName} => ({ object: ${createObjectName}(camino, request.objectId), camino, request, }); export const ${serveName} = (implementation: ${implementationTypeName}) => { serveQuixosPackageRuntime({ packageNamespace, packageName, runtimeProtocolVersion, functions, implementation, createContext: create${className}Context, }); }; `; }; const implementationNamed = (schema, name) => schema.implements.find((entry) => entry.interface.namespace === "quixos.react" && entry.interface.name === name); const requiredBinding = (implementation, name) => { 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, 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 = string & { readonly $caminoClass: ClassId; }; export type LiveFieldProp = { value: T; source: { objectId: string; fieldName: string; fieldType?: string; fieldStorage?: string; conflictStrategy?: string; revision?: string | number | bigint; }; }; export type ReactComponentHostProps = { onAction?: (action: Action) => void; fallback?: unknown; className?: string; style?: Record; onError?: (error: Error) => void; }; export type ReactComponentImplementationProps = { camino: CaminoProps; render: RenderProps; dispatch: (action: Action) => void; }; export const createReactComponentFor: < ForObject extends ObjectRef, RenderProps extends object, Action, >(config: { componentClassId: string; projection: string; }) => (props: { forObject: ForObject; } & RenderProps & ReactComponentHostProps) => any; export const h: (...args: any[]) => any; export const useLiveField: ( field: LiveFieldProp, ) => [T, (value: T) => void | Promise]; export const Automerge: typeof AutomergeNamespace; } `; const renderServer = (schema, implementationImport) => { const className = schema.id.name; return `${generatedWarning}import * as implementation from ${stringLiteral(implementationImport)}; import { serve${className}Runtime } from "./${schema.id.namespace}.${className}.runtime.js"; serve${className}Runtime(implementation); `; }; const main = async () => { 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}`); } const schema = compiled.classes[0]; if (!schema) { throw new Error(`No Camino class found in ${sourceFile}`); } fs.mkdirSync(outDir, { recursive: true }); const baseName = `${schema.id.namespace}.${schema.id.name}`; 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();