Files
camino-package-runtime/dist/index.js
T

375 lines
18 KiB
JavaScript

import http from "node:http";
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import { create, equals } from "@bufbuild/protobuf";
import { Code, ConnectError, createClient } from "@connectrpc/connect";
import { connectNodeAdapter, createConnectTransport } from "@connectrpc/connect-node";
import { CaminoService, CrdtValueSchema, ListValueSchema, NullValueSchema, ObjectValueSchema, RefValueSchema, ValueSchema, } from "./camino/api_pb.js";
import { OrchestratorRuntime } from "./quixos/orch_pb.js";
import { DerivedDependencySchema, HandshakeResponseSchema, InvokeResponseSchema, PackageRuntime, WatchEventSchema, } from "./quixos/runtime_pb.js";
import { CapabilityRefSchema } from "./quixos/refs_pb.js";
const dependencyKey = (dependency) => `${dependency.kind}:${dependency.objectId}:${dependency.attachmentId}:${dependency.kind === "edge" ? dependency.projectionId : ""}`;
const dependencyScope = new AsyncLocalStorage();
const recordDependency = async (dependency) => {
const scope = dependencyScope.getStore();
if (!scope)
return;
const key = `${dependency.kind}:${dependency.objectId}:${dependency.attachmentId}:${dependency.kind === "edge" ? dependency.projectionId : ""}`;
scope.dependencies.set(key, dependency);
await scope.observe?.(dependency);
};
const bytesToBase64 = (value) => Buffer.from(value).toString("base64");
const base64ToBytes = (value) => Buffer.from(value, "base64");
const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
const isWrappedValue = (value) => isRecord(value) && "$quixosValue" in value &&
isRecord(value.$quixosValue) && value.$quixosValue.$typeName === "camino.Value";
export const objectRef = (objectId) => ({ $quixosRef: objectId });
export const liveValue = (value) => ({ $quixosValue: value });
export const jsToProtoValue = (value) => {
if (isWrappedValue(value))
return value.$quixosValue;
if (value === null || value === undefined) {
return create(ValueSchema, { kind: { case: "nullValue", value: create(NullValueSchema, {}) } });
}
if (typeof value === "boolean")
return create(ValueSchema, { kind: { case: "boolValue", value } });
if (typeof value === "number")
return create(ValueSchema, { kind: { case: "numberValue", value } });
if (typeof value === "bigint")
return create(ValueSchema, { kind: { case: "integerValue", value: String(value) } });
if (typeof value === "string")
return create(ValueSchema, { kind: { case: "stringValue", value } });
if (value instanceof Uint8Array)
return create(ValueSchema, { kind: { case: "bytesValue", value } });
if (Array.isArray(value)) {
return create(ValueSchema, {
kind: { case: "listValue", value: create(ListValueSchema, { values: value.map(jsToProtoValue) }) },
});
}
if (isRecord(value) && typeof value.$quixosRef === "string") {
return create(ValueSchema, {
kind: { case: "refValue", value: create(RefValueSchema, { objectId: value.$quixosRef }) },
});
}
if (isRecord(value) && typeof value.$quixosCrdtType === "string" &&
typeof value.$quixosCrdtPayload === "string") {
return create(ValueSchema, {
kind: { case: "crdtValue", value: create(CrdtValueSchema, {
type: value.$quixosCrdtType,
encoding: typeof value.$quixosCrdtEncoding === "string" ? value.$quixosCrdtEncoding : "base64",
payload: base64ToBytes(value.$quixosCrdtPayload),
}) },
});
}
if (!isRecord(value))
throw new Error(`Unsupported runtime value ${typeof value}`);
return create(ValueSchema, {
kind: { case: "objectValue", value: create(ObjectValueSchema, {
fields: Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, jsToProtoValue(entry)])),
}) },
});
};
export const protoValueToJs = (value) => {
switch (value?.kind.case) {
case "nullValue":
case undefined: return null;
case "boolValue":
case "numberValue":
case "stringValue":
case "integerValue": return value.kind.value;
case "bytesValue": return bytesToBase64(value.kind.value);
case "refValue": return value.kind.value.objectId;
case "listValue": return value.kind.value.values.map(protoValueToJs);
case "objectValue": return Object.fromEntries(Object.entries(value.kind.value.fields).map(([key, entry]) => [key, protoValueToJs(entry)]));
case "crdtValue": return {
$quixosCrdtType: value.kind.value.type,
$quixosCrdtEncoding: value.kind.value.encoding,
$quixosCrdtPayload: bytesToBase64(value.kind.value.payload),
};
}
};
export const protoFieldsToJs = (fields) => Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, protoValueToJs(value)]));
const targetForEdge = (edge, projectionId) => edge.firstProjectionId === projectionId ? edge.secondObjectId : edge.firstObjectId;
export const createRuntimeContext = (camino, orch, request) => {
const ports = new Map();
for (const dependency of request.dependencies) {
switch (dependency.binding.case) {
case "stateSlotId": {
const slotId = dependency.binding.value;
const dependencyObjectId = dependency.objectId || request.objectId;
const state = {
slotId,
async get() {
await recordDependency({ kind: "state", objectId: dependencyObjectId, attachmentId: slotId });
return protoValueToJs((await camino.readState({ objectId: dependencyObjectId, slotId })).value);
},
async live() {
await recordDependency({ kind: "state", objectId: dependencyObjectId, attachmentId: slotId });
const value = await camino.readState({ objectId: dependencyObjectId, slotId });
if (!value.value)
throw new Error(`State ${slotId} returned no value`);
return liveValue(value.value);
},
async set(value) {
await camino.writeState({ objectId: dependencyObjectId, slotId, value: jsToProtoValue(value) });
},
};
ports.set(dependency.portId, state);
break;
}
case "edge": {
const { edgeTypeId, projectionId } = dependency.binding.value;
const dependencyObjectId = dependency.objectId || request.objectId;
const edge = {
edgeTypeId,
projectionId,
async resolve() {
await recordDependency({ kind: "edge", objectId: dependencyObjectId, attachmentId: edgeTypeId, projectionId });
const result = await camino.resolveEdge({ objectId: dependencyObjectId, edgeTypeId, projectionId });
return result.edges.map((entry) => targetForEdge(entry, projectionId));
},
async connect(targetObjectId) {
await camino.connectEdge({ objectId: dependencyObjectId, edgeTypeId, projectionId, targetObjectId });
},
};
ports.set(dependency.portId, edge);
break;
}
case "receiverInterfaceRevisionId": {
const interfaceRevisionId = dependency.binding.value;
const capability = {
interfaceRevisionId,
async invoke(operationId, input = {}) {
const response = await orch.invokeCapability({
capability: create(CapabilityRefSchema, { interfaceRevisionId, operationId }),
objectId: request.objectId,
input: Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsToProtoValue(value)])),
});
if (!response.ok)
throw new Error(response.error || `Capability ${operationId} failed`);
return protoValueToJs(response.result);
},
};
ports.set(dependency.portId, capability);
break;
}
case "constructorAtomId": {
const atomId = dependency.binding.value;
const constructor = {
atomId,
async construct(input = {}) {
const response = await orch.constructObject({
atomId,
input: Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsToProtoValue(value)])),
});
if (!response.object)
throw new Error(`Constructor ${atomId} returned no object`);
return response.object.id;
},
};
ports.set(dependency.portId, constructor);
}
}
}
const requirePort = (portId, kind) => {
const port = ports.get(portId);
if (!port || !(kind in port))
throw new Error(`Missing ${kind} dependency port ${portId}`);
return port;
};
return {
objectId: request.objectId,
input: protoFieldsToJs(request.input),
inputProto: request.input,
ports,
state: (portId) => requirePort(portId, "slotId"),
edge: (portId) => requirePort(portId, "edgeTypeId"),
interface: (portId) => requirePort(portId, "interfaceRevisionId"),
constructor: (portId) => requirePort(portId, "atomId"),
};
};
export const derived = (get) => ({ kind: "derived", get });
const isDerived = (handler) => typeof handler === "object" && handler.kind === "derived";
const evaluate = async (handler, context, observe) => {
const dependencies = new Map();
const result = await dependencyScope.run({ dependencies, observe }, () => isDerived(handler) ? handler.get(context) : handler(context));
return { value: jsToProtoValue(result), dependencies: [...dependencies.values()] };
};
const protoDependencies = (dependencies) => dependencies.map((entry) => create(DerivedDependencySchema, {
kind: entry.kind,
objectId: entry.objectId,
attachmentId: entry.attachmentId,
projectionId: entry.kind === "edge" ? entry.projectionId : "",
}));
export const createPackageRuntimeRoutes = (config) => {
const headers = {};
if (process.env.CAMINO_RUNTIME_AUTH_TOKEN) {
headers["x-camino-runtime-token"] = process.env.CAMINO_RUNTIME_AUTH_TOKEN;
}
else if (process.env.CAMINO_RUNTIME_AUTH_REQUIRED === "1") {
throw new Error("CAMINO_RUNTIME_AUTH_TOKEN is required");
}
const camino = createClient(CaminoService, createConnectTransport({
baseUrl: config.caminoUrl ?? process.env.CAMINO_URL ?? "http://127.0.0.1:7310",
httpVersion: "1.1",
interceptors: headers["x-camino-runtime-token"] ? [
(next) => async (request) => {
request.header.set("x-camino-runtime-token", headers["x-camino-runtime-token"]);
return await next(request);
},
] : [],
}));
const orch = createClient(OrchestratorRuntime, createConnectTransport({
baseUrl: config.orchUrl ?? process.env.QUIXOS_ORCH_URL ?? "http://127.0.0.1:7311",
httpVersion: "1.1",
}));
return (router) => router.service(PackageRuntime, {
handshake: () => create(HandshakeResponseSchema, {
packageRevisionId: config.packageRevisionId,
runtimeProtocolVersion: "quixos-capabilities-v1",
exportIds: Object.keys(config.exports),
}),
invoke: async (request) => {
const exportId = request.export?.exportId;
const handler = exportId ? config.exports[exportId] : undefined;
if (!handler)
throw new ConnectError(`Unknown export ${exportId ?? ""}`, Code.NotFound);
try {
const result = await evaluate(handler, createRuntimeContext(camino, orch, request));
return create(InvokeResponseSchema, { ok: true, result: result.value });
}
catch (error) {
return create(InvokeResponseSchema, {
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
},
watch: async function* (request, context) {
const exportId = request.export?.exportId;
const handler = exportId ? config.exports[exportId] : undefined;
if (!handler || !isDerived(handler)) {
throw new ConnectError(`Export ${exportId ?? ""} is not derived`, Code.FailedPrecondition);
}
const watchId = `watch:${randomUUID()}`;
const runtimeContext = createRuntimeContext(camino, orch, request);
const subscriptions = new Map();
const establishing = new Map();
const ensureSubscription = async (dependency) => {
const key = dependencyKey(dependency);
if (subscriptions.has(key))
return;
const pending = establishing.get(key);
if (pending)
return await pending;
const establish = (async () => {
const controller = new AbortController();
const stream = camino.watchObject({ objectId: dependency.objectId, includeSnapshot: true }, { signal: controller.signal })[Symbol.asyncIterator]();
try {
// Camino subscribes before producing the snapshot, so once this
// resolves the following state/edge read cannot race the stream.
const snapshot = await stream.next();
if (snapshot.done)
throw new Error(`Dependency stream ${key} ended during setup`);
const waitNext = () => stream.next().then((result) => ({ key, done: Boolean(result.done) }), (error) => ({ key, done: true, error }));
const subscription = {
dependency,
controller,
waitNext,
next: Promise.resolve({ key, done: false }),
};
subscription.next = waitNext();
subscriptions.set(key, subscription);
}
catch (error) {
controller.abort();
throw error;
}
})();
establishing.set(key, establish);
try {
await establish;
}
finally {
establishing.delete(key);
}
};
const abortAll = () => {
for (const subscription of subscriptions.values()) {
subscription.controller.abort();
}
};
context.signal.addEventListener("abort", abortAll, { once: true });
let current = await evaluate(handler, runtimeContext, ensureSubscription);
yield create(WatchEventSchema, {
watchId,
value: current.value,
dependencies: protoDependencies(current.dependencies),
initial: true,
});
const abort = new Promise((resolve) => {
if (context.signal.aborted)
resolve("abort");
else
context.signal.addEventListener("abort", () => resolve("abort"), { once: true });
});
try {
while (!context.signal.aborted) {
if (subscriptions.size === 0) {
await abort;
break;
}
const outcome = await Promise.race([
...[...subscriptions.values()].map((entry) => entry.next),
abort,
]);
if (outcome === "abort")
break;
const subscription = subscriptions.get(outcome.key);
if (!subscription)
continue;
if (outcome.error)
throw outcome.error;
if (outcome.done)
throw new Error(`Dependency stream ${outcome.key} ended unexpectedly`);
subscription.next = subscription.waitNext();
const updated = await evaluate(handler, runtimeContext, ensureSubscription);
const active = new Set(updated.dependencies.map(dependencyKey));
for (const [key, entry] of subscriptions) {
if (!active.has(key)) {
entry.controller.abort();
subscriptions.delete(key);
}
}
if (!equals(ValueSchema, current.value, updated.value)) {
yield create(WatchEventSchema, {
watchId,
value: updated.value,
dependencies: protoDependencies(updated.dependencies),
});
}
current = updated;
}
}
finally {
context.signal.removeEventListener("abort", abortAll);
abortAll();
}
},
});
};
export const servePackageRuntime = (config) => {
const host = process.env.QUIXOS_RUNTIME_HOST ?? "127.0.0.1";
const port = Number(process.env.QUIXOS_RUNTIME_PORT ?? "0");
const handler = connectNodeAdapter({ routes: createPackageRuntimeRoutes(config) });
const server = http.createServer((request, response) => void handler(request, response));
server.listen(port, host, () => console.log(`${config.packageRevisionId} listening on ${host}:${port}`));
const shutdown = () => {
server.close(() => process.exit(0));
server.closeAllConnections();
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
return server;
};