Files
camino-package-runtime/dist/codegen-ts-runtime.js
T
2026-07-18 10:35:48 -07:00

519 lines
20 KiB
JavaScript
Executable File

#!/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] <class-schema.camino.proto> <out-dir> [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<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, 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<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");
};
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<string> & {
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<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, 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();