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

johnandersen777.bsky.social/atlibs

0
0
main
275 lines · 8.3 KB Raw History

Write a module

A module is a Rust crate built to wasm32-unknown-unknown, written against the abi crate (rust/src/abi). Each entry is a plain function from the run's input and event to a Handle: the calls to make, the new state, or the result. It has no I/O of its own.

A module crate

examples/rust/hello counts an account's records in a collection. Any directory under examples/rust/ is a workspace member:

Cargo.toml

members = ["rust/src/abi", "examples/rust/*"]

examples/rust/hello/Cargo.toml

[package]
name = "hello"
version = "0.1.0"
edition = "2024"
publish = false

[lib]
crate-type = ["cdylib"]

[dependencies]
abi.workspace = true
serde.workspace = true
serde_json.workspace = true

examples/rust/hello/src/lib.rs

use abi::{Call, Did, Event, Handle, Run, entries};
use serde::Deserialize;
use serde_json::json;

// Every event gets the action's input plus did and now; Run adds the event and the last state.
#[derive(Deserialize)]
struct Args {
    did: Did,
    #[serde(default)]
    collection: Option<String>,
}

fn count_handler(run: Run<Args>) -> Result<Handle, String> {
    let collection = run.args.collection.ok_or("collection is required")?;
    Ok(match run.event {
        // First event: list the records. service = the account's DID: a public read of its PDS.
        Event::Start => Handle::new().call(
            Call::new("list", "com.atproto.repo.listRecords")
                .params(json!({ "repo": run.args.did, "collection": collection, "limit": "100" }))
                .service(run.args.did.as_str()),
        ),
        // The result comes back with the id we gave it. A failed call is the run's error.
        Event::Result { id, result, .. } if id == "list" => {
            let records = result.output()?["records"].as_array().map_or(0, Vec::len);
            Handle::new().result(json!({ "collection": collection, "records": records })).done()
        }
        _ => Handle::new(),
    })
}

entries!(count => count_handler);

entries! exports alloc and one function per entry. Did rejects anything that is not a DID.

module.json

examples/rust/hello/module.json

{
	"name": "Hello",
	"description": "Count an account's records in a collection.",
	"actions": [{ "entry": "count", "label": "Count", "fields": [{ "key": "collection", "label": "Collection" }] }],
	"calls": ["com.atproto.repo.listRecords"]
}
fieldwhat
name, descriptionshown by serve, pages and the published record
actionsthe entries people run: entry, label, description, readOnly (changes none of the user's data), fields (keyed by input key: label, type, required, description); MCP servers serve them as tools
callsthe NSIDs the module may call; anything else is refused
sessionthe rkey of its com.fedfork.exec.oauth.clientMetadata record (records/<session>.json): the scope a signed-in run gets. No session: public reads only
anyRecordscollections it may update or delete records in even if this host did not create them
hooks, implements, anyHookscalls to and from other modules: a route reaches the entry its target implements (Compositions)
configthe input a composition's instance may give it, as fields
didkey{ "services": [...] }: the host gives the module its own did:key

Build and run it

cargo wasm
ls target/wasm32-unknown-unknown/release/hello.wasm
atlibs run examples/rust/hello 2>&1 | tail -1
target/wasm32-unknown-unknown/release/hello.wasm
hello entries: count

Builds are always release builds: a debug module is ~20 MB, past the 4 MB a host loads.

Events and handles

Run<Args> carries args (the input: your fields plus did and now), state (what you returned last, null at first) and event:

rust/src/abi/src/lib.rs

pub enum Event {
    Start,
    /// A call finished (or was refused); `result.output()` is its output or error.
    Result {
        id: String,
        nsid: String,
        result: CallResult,
    },
    /// One message on an open subscription; `dropped` counts messages lost before it.
    Message {
        id: String,
        data: Value,
        dropped: u64,
    },
    /// A subscription ended; `error` is None for a clean close.
    Closed {
        id: String,
        error: Option<String>,
    },
    Timer {
        id: String,
    },
    /// An external event fed into the run.
    Input {
        data: Value,
    },
    /// A capability this run passed in a call's input was invoked: `id` is that call,
    /// `cap` the name the run gave it. Answer by calling `reply` (a capability) with the answer as input.
    Callback {
        id: String,
        cap: String,
        input: Value,
        reply: String,
    },
    /// An event type newer than this crate.
    Unknown,
}

A Handle says what happens next:

methoddoes
.call(Call::new(id, nsid)...)make an XRPC call; its result arrives as Event::Result { id }
.timer(id, ms)Event::Timer { id } after ms
.send(id, data) / .cancel(id)write up / close a subscription, or cancel a call
.state(value)the state the next event gets
.result(value)the run's answer
.done()end the run now (otherwise it ends when nothing is pending)

An Err(String) from a handler ends the run with that error.

State, typed ids and fan-out

For more than one or two calls, Step keeps typed state and typed call ids, and Fan collects a batch of results that arrive one by one. statusphere's clear_status lists statuses, deletes each, and counts:

examples/rust/statusphere/src/lib.rs

abi::call_ids! {
    #[derive(Clone, Copy)]
    enum Id {
        List = "list",
        Create = "create",
        Delete(usize) = "delete",
    }
}

examples/rust/statusphere/src/lib.rs

struct ClearStatus {
    deletes: Fan,
    deleted: u64,
    failed: u64,
}

examples/rust/statusphere/src/lib.rs

// list up to CLEAR_LIMIT statuses -> delete each (results arrive one by one) -> count.
fn clear_status_handler(run: Run<Args>) -> Result<Handle, String> {
    let (mut step, on) = Step::<Args, ClearStatus, Id>::new(run)?;
    let did = step.args.did.as_str();
    match on {
        On::Start => step.out.call(Id::List, list_statuses(&step.args.did, CLEAR_LIMIT)),
        On::Result(Id::List, result) => {
            let output = result?;
            let rkeys: Vec<String> = output["records"].as_array().into_iter().flatten().filter_map(|record| Some(record["uri"].as_str()?.parse::<AtUri>().ok()?.rkey)).take(CLEAR_LIMIT).collect();
            if rkeys.is_empty() {
                step.out.finish(json!({ "deleted": 0 }));
                return step.handle();
            }
            let deletes = rkeys.iter().enumerate().map(|(i, rkey)| (Id::Delete(i), delete_record(&AtUri::new(did, COLLECTION, rkey))));
            step.out.fan_out(&mut step.state.deletes, deletes);
        }
        On::Result(id @ Id::Delete(_), result) => {
            let s = &mut step.state;
            if result.is_ok() { s.deleted += 1 } else { s.failed += 1 }
            if s.deletes.land(&id, result).is_some() {
                step.out.finish(json!({ "deleted": s.deleted, "failed": s.failed }));
            }
        }
        _ => {}
    }
    step.handle()
}

Records

abi::repo builds the repo calls; a type with LexiconType is written with its $type:

examples/rust/statusphere/src/lib.rs

#[derive(Serialize, PartialEq)]
struct Status {
    status: String,
    #[serde(rename = "createdAt")]
    created_at: Datetime,
}

examples/rust/statusphere/src/lib.rs

impl LexiconType for Status {
    fn lexicon_type() -> &'static str {
        COLLECTION
    }
}

examples/rust/statusphere/src/lib.rs

            step.out.call(Id::Create, create(args.did.as_str(), Status { status, created_at }));

Rules

  • No imports: a module exports memory, alloc and its entries, and nothing else.
  • Input and output are JSON. Modules have no clock: use now from the input.
  • Each event gets 5 s; calls, events and wall time per run are budgeted (limits).
  • Only calls NSIDs; writes only to records this host created for it, or in anyRecords.

Previous: CLI | Next: Calls