# 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` ```toml members = ["rust/src/abi", "examples/rust/*"] ``` `examples/rust/hello/Cargo.toml` ```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` ```rust 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, } fn count_handler(run: Run) -> Result { 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` ```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"] } ``` | field | what | |---|---| | `name`, `description` | shown by `serve`, pages and the published record | | `actions` | the 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](../server/mcp.md) serve them as tools | | `calls` | the NSIDs the module may call; anything else is refused | | `session` | the rkey of its `com.fedfork.exec.oauth.clientMetadata` record (`records/.json`): the scope a signed-in run gets. No session: public reads only | | `anyRecords` | collections it may update or delete records in even if this host did not create them | | `hooks`, `implements`, `anyHooks` | calls to and from other modules: a route reaches the entry its target `implements` ([Compositions](../compositions.md)) | | `config` | the `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 ```bash run=gate cargo wasm ``` ```bash run ls target/wasm32-unknown-unknown/release/hello.wasm atlibs run examples/rust/hello 2>&1 | tail -1 ``` ```output 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` 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` ```rust 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, }, 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: | method | does | |---|---| | `.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` ```rust abi::call_ids! { #[derive(Clone, Copy)] enum Id { List = "list", Create = "create", Delete(usize) = "delete", } } ``` `examples/rust/statusphere/src/lib.rs` ```rust struct ClearStatus { deletes: Fan, deleted: u64, failed: u64, } ``` `examples/rust/statusphere/src/lib.rs` ```rust // list up to CLEAR_LIMIT statuses -> delete each (results arrive one by one) -> count. fn clear_status_handler(run: Run) -> Result { let (mut step, on) = Step::::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 = output["records"].as_array().into_iter().flatten().filter_map(|record| Some(record["uri"].as_str()?.parse::().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` ```rust #[derive(Serialize, PartialEq)] struct Status { status: String, #[serde(rename = "createdAt")] created_at: Datetime, } ``` `examples/rust/statusphere/src/lib.rs` ```rust impl LexiconType for Status { fn lexicon_type() -> &'static str { COLLECTION } } ``` `examples/rust/statusphere/src/lib.rs` ```rust 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](../reference/limits.md)). - Only `calls` NSIDs; writes only to records this host created for it, or in `anyRecords`. Previous: [CLI](../cli.md) | Next: [Calls](calls.md)