~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
309 lines · 12.2 KB Raw History

Compositions

A composition runs several modules as one program. A module calls an NSID; the composition routes that call to a module that implements it, or to a handler the page or server supplies. Callers can pass callbacks, and policies can veto a call before it is made.

Write one

In TypeScript (or Rust), from bindings of the modules' module.json; the composition.json it writes is the file hosts load, published and reviewed. Commit both.

examples/rust/compose/compose.ts

const ui = c.instance("ui", compose);
const lookup = c.instance("lookup", compose, { input: { n: 0 } });
const even = c.instance("even", compose);
const confirm = c.handler("confirm", "The page asks the user.");
c.route(ui, "com.example.compose.lookup", lookup, "ui needs n doubled", { policies: [policy(even, "even")] });
c.route(ui, "com.example.compose.confirm", confirm, "ui asks the user before acting");
mkdir b && atlibs compose bindings --dir b examples/rust/compose 2>/dev/null
diff b/atlibs.modules.js examples/rust/compose/atlibs.modules.js && echo bindings unchanged
node examples/rust/compose/compose.ts | diff - examples/rust/compose/composition.json && echo composition unchanged
bindings unchanged
composition unchanged

atlibs compose bindings writes atlibs.modules.js and .d.ts (atlibs add does too, for what it vendors): a route's NSID must be a hook of the caller and its target must implement it, an input key must be in the module's config, of its type. build() runs the hosts' load checks and names your line:

composition: edge ui com.example.compose.confirm: x (compose) does not implement com.example.compose.confirm
    at compose.ts:9:3
  • instances: a module under a name, with optional fixed input (keys from the module's config). The same module may appear twice.
  • handlers: names the caller (page, server, CLI) must supply.
  • edges (route): from calling nsid goes to an instance, at the entry its module implements for nsid, or to a handler. reason is shown wherever the call is logged (default: the hook's). policies must allow it first.
  • limits: call depth and total routed calls (defaults 8 and 100).

Declare what a module needs: hooks

A module lists the routed NSIDs it calls in hooks, so a composition that forgets one fails at load, not halfway through a run:

examples/rust/compose/module.json

	"hooks": [
		{ "nsid": "com.example.compose.lookup", "reason": "the example's lookup: another instance doubles n" },
		{ "nsid": "com.example.compose.confirm", "reason": "the example's confirm: the caller's handler" }
	]
hook fieldwhat
nsidthe contract: a lexicon procedure
reasonwhy the module needs it
requiredno route and no default: refuse the composition at load (default: the call answers null)
defaulta module that serves it when nothing routes it (the same publisher's, at the entry its implements names), as instance <instance>-<default>: one per module, shared by the hooks that name it
combine: "all-allow"any number of routes, each answering a policy verdict; every one must allow (Policies)
entriesthe entries that call it (default: all)

A module that serves a contract says so in implements, and the input an instance may give it in config (fields as in actions):

examples/rust/compose/module.json

	"config": [{ "key": "n", "label": "n", "type": "integer", "description": "The n lookup doubles when a call gives none." }],
	"calls": [],
	"implements": [{ "nsid": "com.example.compose.lookup", "entry": "lookup" }],

An instance's input key outside config, a value of another type, or a missing required field fails the load. atlibs compose check <file> runs these checks without the wasm (CLI); JS: checkComposition(manifest, specOf).

Run it

atlibs run examples/rust/compose/composition.json ui caller '{"nsid":"com.example.compose.lookup","input":{"n":4}}' \
  --handler confirm=wasm:examples/rust/compose#lookup | jq -c .result.q.output.doubled
8

ui asked lookup to double 4; the even policy allowed it. With 3 the policy denies, and ui gets a failed result instead:

atlibs run examples/rust/compose/composition.json ui caller '{"nsid":"com.example.compose.lookup","input":{"n":3}}' \
  --handler confirm=wasm:examples/rust/compose#lookup | jq -c .result.q
{"error":"policy even denied: n 3 is not even","ok":false}

The run also prints a graph: every routed call, handler call, callback and policy run.

Pass a callback

A caller puts cap("name") in a call's input. The callee sees a capability token and can call it; the caller gets Event::Callback and answers by calling reply:

examples/rust/compose/src/lib.rs

fn caller_handler(run: Run<Value>) -> Result<Handle, String> {
    let mut state = if run.state.is_null() { json!({ "progress": [] }) } else { run.state };
    Ok(match run.event {
        Event::Start => {
            let nsid = run.args["nsid"].as_str().ok_or("nsid is required")?;
            let mut input = run.args.get("input").cloned().unwrap_or_else(|| json!({}));
            input["progress"] = cap("progress");
            let call = Call::new("q", nsid).input(input);
            let call = match run.args["proxy"].as_str() {
                Some(proxy) => call.proxy(proxy),
                None => call,
            };
            Handle::new().call(call).state(state)
        }
        Event::Callback { cap, input, reply, .. } if cap == "progress" => {
            let list = state["progress"].as_array_mut().ok_or("bad state")?;
            list.push(input.clone());
            let id = format!("reply-{}", list.len());
            Handle::new().call(Call::new(id, reply).input(json!({ "seen": input }))).state(state)
        }
        Event::Result { id, result, .. } if id == "q" => {
            let outcome = match result.output() {
                Ok(output) => json!({ "ok": true, "output": output }),
                Err(error) => json!({ "ok": false, "error": error }),
            };
            Handle::new().result(json!({ "q": outcome, "progress": state["progress"] }))
        }
        _ => Handle::new(),
    })
}

examples/rust/compose/src/lib.rs

fn lookup_handler(run: Run<Value>) -> Result<Handle, String> {
    let n = run.args["n"].as_i64().ok_or("n is required")?;
    let answer = |ack: Value| Handle::new().result(json!({ "doubled": n * 2, "ack": ack, "caller": run.args["caller"] }));
    Ok(match (run.event, cap_token(&run.args["progress"])) {
        (Event::Start, Some(token)) => Handle::new().call(Call::new("p", token).input(json!({ "step": 1 }))),
        (Event::Start, None) => answer(Value::Null),
        (Event::Result { id, result, .. }, _) if id == "p" => answer(result.output().cloned().unwrap_or_else(|e| json!({ "error": e }))),
        _ => Handle::new(),
    })
}

Capabilities are unforgeable per run and die with it. A policy never receives the caller's capabilities.

Handlers from the caller

On the command line a handler is another module's entry, a URL, or a command (CLI: run). In JS, runCompose takes functions:

import { readFile } from "node:fs/promises";

import { loadComposition, runCompose } from "./javascript/src/compose/index.js";
import { localModule } from "./javascript/src/run-module/index.js";
import { createNodeContext, fileFetch } from "./javascript/src/server/index.js";

const ctx = createNodeContext({ id: "compose-demo", stateDir: ".state/compose-demo" });
const root = `file://${process.cwd()}/`;
const manifest = JSON.parse(await readFile("examples/rust/compose/composition.json", "utf8"));
// every instance here is the module "compose": load the local build for it
const load = () => localModule(new URL("target/wasm32-unknown-unknown/release/compose.wasm", root), new URL("examples/rust/compose/module.json", root), fileFetch);
const composition = await loadComposition(ctx, manifest, { load });

// the handler the manifest declares; a page would ask the user here
const confirm = async (input, { caller }) => ({ confirmed: input.n, by: caller });
const ran = await runCompose(ctx, composition, "ui", "caller", { nsid: "com.example.compose.confirm", input: { n: 1 } }, { handlers: { confirm } });
console.log(JSON.stringify(ran.result.q.output));
{"by":"ui","confirmed":1}

In a page, pass callFor: (instance) => policyCall(stage, instance.module, calls) so each instance runs under its own module's session (Sign in from a page).

Parameters

Inputs a binding fills. Types as module.json fields; values holds default and enum; a secret comes from secrets only. {"$param": key} in an instance's input takes the value; {"$host": name}, one only the host gives.

cat > params.json <<'JSON'
{
	"$type": "com.fedfork.exec.composition",
	"name": "Doubler",
	"parameters": [{ "key": "n", "type": "integer", "maximum": 100, "values": { "default": 4 } }],
	"instances": [{ "name": "lookup", "module": "compose", "input": { "n": { "$param": "n" } } }],
	"exports": [{ "nsid": "com.example.compose.lookup", "instance": "lookup", "entry": "lookup" }]
}
JSON
atlibs run params.json lookup lookup | jq -c .result.doubled
atlibs run params.json lookup lookup --params '{"inputs":{"n":21}}' | jq -c .result.doubled
atlibs run params.json lookup lookup --params '{"inputs":{"n":200}}' 2>&1 | tail -1
8
42
composition: parameter n must be at most 100

--params is {inputs, secrets, host}. JS: bindParameters(manifest, { inputs, secrets, host }) before runCompose, which refuses a placeholder left unbound. exports names the entry serving a contract: exportOf(manifest, nsid).

A com.fedfork.exec.binding record is a composition pinned by CID, its inputs, and the names of its secrets. Publishing writes the composition at <name> and at <name>.<its CID>; the CID loads the latter, so a republish changes no binding.

Run one from your own host

A Rust host loads a published composition pinned by CID, and signs its users in once per composition, with only the scopes its modules need:

rust/host/src/registry.rs

pub async fn published(net: &Net, uri: &str, cid: Option<&str>) -> Result<(String, Composition, Vec<(String, Lib)>), String> {

rust/host/src/signin.rs

pub fn scope_of(comp: &Composition) -> String {
…
pub fn composition_prefix(cid: &str) -> String {
…
pub fn composition_store(state_dir: &Path, cid: &str) -> Result<Store, String> {

registry::module_call gives each instance its own call (its session's scope, attest, did:key, transports); params::bind, then compose::run_compose, runs an export (params::export_of).

In Rust

atlibs-compose (rust/compose, by git like atlibs-host): a build.rs generates the same bindings as types, so a wrong route or input does not compile; check(dir) runs atlibs compose check.

examples/rust-compose/src/lib.rs

    let lookup = c.instance::<Compose>("lookup").input(ComposeInput::default().n(0));
…
    c.route(&ui, compose::Lookup, &lookup, "ui needs n doubled").policy(&even, Compose::even);
cargo run -q --release --manifest-path rust/Cargo.toml -p rust-compose --target host-tuple --target-dir rust/host/target | diff - examples/rust/compose/composition.json
error[E0277]: module `Compose` does not implement `Confirm`
   = note: route it to an instance whose module implements it

Stage and publish it

atlibs stage copies a composition and every module it names; atlibs add fetches a published one with its modules.

atlibs stage --target spa --origin https://you.example/ --out site examples/rust/compose/composition.json > /dev/null
find site -type f | LC_ALL=C sort
site/composition.json
site/modules/compose.json
site/modules/compose.wasm
site/stage.json
atlibs publish composition examples/rust/compose/composition.json --as you.bsky.social

A published composition's instance module keys become your own module records; an instance may also pin a module with pin: it loads that version, so the module's republish changes nothing.

Previous: Publish a module | Next: Policies