Publish built Camino package runtime artifacts
This commit is contained in:
Vendored
+299
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { compileCaminoSchema } from "./schema-compiler.js";
|
||||
const usage = () => {
|
||||
console.error("Usage: camino-codegen-ts-runtime <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 tsTypeForTypeRef = (type) => {
|
||||
if (type.enumValues && type.enumValues.length > 0) {
|
||||
return type.enumValues.map(stringLiteral).join(" | ");
|
||||
}
|
||||
switch (type.protoType.replace(/^\./, "")) {
|
||||
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:
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
const tsTypeForField = (field) => tsTypeForTypeRef(field.type);
|
||||
const tsValueTypeForField = (field) => field.repeated ? `${tsTypeForField(field)}[]` : tsTypeForField(field);
|
||||
const tsFieldApiType = (field) => field.conflict === "crdt"
|
||||
? `CrdtField<${tsValueTypeForField(field)}>`
|
||||
: `Field<${tsValueTypeForField(field)}>`;
|
||||
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 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) => schema.implements.map((implementation) => {
|
||||
const typeArgs = implementation.typeBindings.map((binding) => tsTypeForTypeRef(binding.type));
|
||||
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) => {
|
||||
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)]
|
||||
.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";
|
||||
|
||||
${interfaceRefTypes ? `${interfaceRefTypes}\n\n` : ""}export type ${refTypeName} = ${refType};
|
||||
|
||||
export type ${objectTypeName} = {
|
||||
id: ${refTypeName};
|
||||
${writableFields
|
||||
.map((field) => ` ${camel(field.name)}: ${tsFieldApiType(field)};`)
|
||||
.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 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 sourceFile = required(process.argv[2]);
|
||||
const outDir = required(process.argv[3]);
|
||||
const implementationImport = process.argv[4] ?? "../task.impl.js";
|
||||
const compiled = await compileCaminoSchema(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}`;
|
||||
fs.writeFileSync(path.join(outDir, `${baseName}.runtime.ts`), renderRuntime(schema));
|
||||
fs.writeFileSync(path.join(outDir, `${baseName}.server.ts`), renderServer(schema, implementationImport));
|
||||
};
|
||||
await main();
|
||||
Reference in New Issue
Block a user