~ 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`

```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<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`

```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/<session>.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<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`

```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<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:

| 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<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`

```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)