/* @ts-self-types="./build.d.ts" */ // Write a composition in code: instances of module bindings (`atlibs compose bindings` writes them beside // atlibs.lock.json), routes by contract, parameters as placeholders. build() runs the hosts' load checks // (checkComposition, no wasm) and points a failure at the line that made the instance or route. // No dependencies; browser, Node and Workers. import { checkComposition } from "./index.js"; const CALLER = /\((.*:\d+:\d+)\)$|at (.*:\d+:\d+)$/; const site = () => { const line = new Error().stack?.split("\n")[3] ?? ""; const m = CALLER.exec(line.trim()); return m ? (m[1] ?? m[2]) : undefined; }; const drop = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)); const list = (xs) => (xs.length ? xs : undefined); /** A value only the host gives: { $host: name }. */ export const host = (name) => ({ $host: name }); /** A policy run before a call: an instance and one of its entries (default evaluate). */ export const policy = (inst, entry, args) => drop({ instance: inst.name, entry, args }); /** A composition; `json()` is the file hosts load. */ export function composition({ name, description, limits }) { const parameters = []; const instances = []; const handlers = []; const edges = []; const exports = []; const sites = new Map(); // "instance " / "edge " -> file:line:col const bindings = new Map(); // module key -> binding const c = { /** A parameter; returns its placeholder for an instance's input. */ param(key, { label, type = "string", required, description, secret, minimum, maximum, values } = {}) { parameters.push(drop({ key, label, type, required, description, secret, minimum, maximum, values })); return { $param: key }; }, /** An instance of a module binding, pinned as vendored (pin: false: unpinned, so it gets hook defaults). */ instance(name, mod, { pin = mod.pin, session, input, calls, policies, budget } = {}) { for (const m of [mod, ...Object.values(mod.lock ?? {})]) bindings.set(m.key, m); instances.push(drop({ name, module: mod.key, pin: pin || undefined, session, input, calls, policies, budget })); sites.set(`instance ${name}`, site()); return { name, module: mod }; }, /** A handler the composition's caller supplies. */ handler(name, description) { handlers.push(drop({ name, description })); return { handler: name }; }, /** `from` calling `nsid` goes to `to` (at the entry its module implements) or to a handler; reason: the hook's. */ route(from, nsid, to, reason = from.module.hooks?.find((h) => h.nsid === nsid)?.reason, { policies } = {}) { edges.push(drop({ from: from.name, nsid, ...(to.handler !== undefined ? { handler: to.handler } : { to: to.name }), reason, policies })); sites.set(`edge ${from.name} ${nsid}`, site()); }, /** The entry serving contract `nsid` (default: the one the instance's module implements). */ export(nsid, inst, entry = inst.module.implements?.find((i) => i.nsid === nsid)?.entry) { exports.push({ nsid, instance: inst.name, entry }); }, /** The manifest, after the hosts' load checks; throws "composition: ... at ". */ async build() { const manifest = drop({ $type: "com.fedfork.exec.composition", name, description, parameters: list(parameters), instances, handlers: list(handlers), edges: list(edges), exports: list(exports), limits, }); try { await checkComposition(manifest, (key) => { const b = bindings.get(key); if (!b) throw new Error(`composition: module ${key} has no binding (atlibs add it)`); return b; }); } catch (error) { const msg = error.message; const edge = /edge (\S+) (\S+?):/.exec(msg)?.slice(1).join(" "); const where = (edge && sites.get(`edge ${edge}`)) ?? sites.get(`instance ${/instance (\S+?):? /.exec(msg)?.[1]}`); throw new Error(where ? `${msg}\n at ${where}` : msg, { cause: error }); } return manifest; }, /** build(), as the file a host loads. */ async json() { return `${JSON.stringify(await c.build(), null, "\t")}\n`; }, }; return c; }