//! Write single-threaded WASI plugins for Sausage. //! //! # Start here //! //! A plugin depends on `sausage-plugin = "0.1"` in its `Cargo.toml`. //! Use Rust edition 2024. The cargo plugin supplies the local SDK dependency. //! The SDK re-exports serde, schemars, serde_json, and its macros. //! Build the plugin through `cargo.build`. Activate the returned WASM file through `core.activate`. //! Read `/proc/core/PROTOCOL.md` for the host contract. //! //! Each core call starts a process. Keep the value passed to [`run`] cheap to construct. //! The core also starts a process just for `rpc.discover`. //! Open files, databases, and listeners in methods, not in `main`. //! Log with `eprintln!`. Stdout belongs to JSON-RPC. //! //! # Methods and schemas //! //! Each `fn` in an [`macro@rpc`] impl becomes an RPC method. //! Put helpers in a separate impl block without `#[rpc]`. //! Both synchronous and async methods must follow these rules: //! //! - Take `&self` first. //! - Optionally take `ctx: &Ctx` next. //! - Take at most one parameter argument after that. //! - Use a [`macro@schema`] struct with named fields for that argument. //! - Return [`Result`]. //! - Give each method, schema type, and field a doc comment. //! //! The parameter fields become the named JSON parameters. //! A method without parameters accepts an empty object. //! Its discovery entry has no `x-sausage-params` field. //! Raw identifiers such as `r#type` use the plain wire name `type`. //! Method docs become the OpenRPC summary and description. //! Type and field docs become schema descriptions. Generated clients retain these docs. //! Docs are part of the interface. Other agents read them to write callers. //! //! `#[schema]` adds serde and schemars derives. Do not add those derives yourself. //! These types form the supported subset: //! //! - `String`, `bool`, and `()`. //! - `u8`, `u16`, `u32`, `u64`, and `usize`. //! - `i8`, `i16`, `i32`, `i64`, and `isize`. //! - `f32` and `f64`. //! - `Vec` and `Option` with supported element types. //! - Other `#[schema]` structs and enums. //! //! Structs must have named fields. Enums must have unit variants. //! Document the variants to explain their meanings to callers. //! The permitted serde attributes are `rename`, `rename_all`, `default`, and `skip_serializing_if`. //! For example, use `#[serde(default)]` for a field with a default value. //! Use `#[serde(default, skip_serializing_if = "Option::is_none")]` for an omitted optional field. //! `#[schema]` rejects other serde attributes and all `#[schemars(...)]` attributes. //! Tuple structs, unit structs, tuple fields, maps, arbitrary JSON values, and data-carrying variants are outside this subset. //! Such fields cannot appear in a `#[schema]` type. //! //! # A persistent plugin //! //! Calls can run concurrently in separate processes. Protect shared changes with [`lock`]. //! Bind the guard to a name: `let _guard = lock(p).await?;`. //! `let _ = lock(p).await?;` releases the lock at once. //! The guard releases the lock when its scope ends. Process exit also releases locks. //! `try_now()` does not wait. Check the returned guard with `held()`. //! `keep()` retains an acquired lock until process exit. //! //! This complete plugin stores notes in [`state_dir`]. [`write_atomic`] replaces the file after a complete temporary write. //! Create the state directory before the first write. //! //! ```no_run //! use sausage_plugin::{lock, rpc, schema, state_dir, write_atomic, Result}; //! use sausage_plugin::serde_json; //! use std::path::PathBuf; //! //! /// Persistent notes shared by all calls. //! struct Notes; //! /// The parameters of add. //! #[schema] //! struct AddParams { //! /// The text to append. Newlines are permitted. //! text: String, //! } //! /// The result of add. //! #[schema] //! struct Added { //! /// The total number of notes. //! count: usize, //! } //! #[rpc] //! impl Notes { //! /// Append a note and return the new count. //! async fn add(&self, p: AddParams) -> Result { //! std::fs::create_dir_all(state_dir())?; //! let _guard = lock(Self::file()).await?; //! let mut notes = Self::load()?; //! notes.push(p.text); //! write_atomic(Self::file(), serde_json::to_vec(¬es)?)?; //! Ok(Added { count: notes.len() }) //! } //! /// List the notes, oldest first. //! fn list(&self) -> Result> { Self::load() } //! } //! // These helpers do not become RPC methods. //! impl Notes { //! fn file() -> PathBuf { state_dir().join("notes.json") } //! fn load() -> Result> { //! match std::fs::read(Self::file()) { //! Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), //! Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]), //! Err(e) => Err(e.into()), //! } //! } //! } //! fn main() { sausage_plugin::run(Notes); } //! ``` //! //! # Calls and process lifetime //! //! [`call`] returns a lazy builder. Await it to send the request and receive its JSON result. //! `detach()` lets the child outlive this process. `on_notify(f)` receives its progress notifications. //! Use [`Ctx::notify`] to send progress to the caller of the current request. //! Do not call [`block_on`] in a handler. The runtime cannot nest `block_on` calls. //! Write an async handler and use `.await` instead. //! //! A process ends after its request finishes if it has no remaining work. //! [`background`] keeps it alive while a task runs. An endpoint listener also keeps it alive. //! Neither mechanism detaches the process from its caller. Start long-lived processes detached or as services. //! At stdin EOF, pending outgoing calls fail with -32000. The SDK stops endpoint listeners and runs the optional stop hook. //! The stop hook can write files and send notifications. It cannot make new calls. //! After the serve loop, the SDK flushes stdout and exits, even if background tasks remain. //! [`Ctx::exit_after_response`] ends the process after its response. This also works in an endpoint handler. //! //! ```no_run //! use sausage_plugin::{background, call, rpc, Ctx, Result}; //! use sausage_plugin::serde_json::json; //! /// A worker that delegates a job. //! struct Worker; //! #[rpc] //! impl Worker { //! /// Start an attached background task and return immediately. //! fn start(&self, ctx: &Ctx) -> Result<()> { //! ctx.notify("text", "worker started")?; //! background(async { //! let result = call("notes.list", json!({})) //! .on_notify(|name, params| eprintln!("{name}: {params}")) //! .await; //! if let Err(error) = result { eprintln!("{error}"); } //! }); //! Ok(()) //! } //! /// Flush local files before this process stops. //! #[sausage_plugin::on_stop] //! fn stop(&self) -> Result<()> { //! eprintln!("worker stopped"); //! Ok(()) //! } //! } //! fn main() { sausage_plugin::run(Worker); } //! ``` //! //! # A server with a process endpoint //! //! Declare endpoint types on the main interface. Publish them with [`listen`]. //! `listen` returns after it publishes the endpoint file, not after the listener ends. //! `/self/control` belongs to this process. Clients find it at `/proc//control`. //! This server counts requests in memory. A new core call cannot inspect the old process's counter. //! //! ```no_run //! use sausage_plugin::{listen, pid, rpc, Ctx, Result}; //! use std::{cell::Cell, rc::Rc}; //! /// A server with process-local counters. //! struct Server; //! /// The interface of one running server. //! struct Control { hits: Cell } //! #[rpc(endpoints(control = Control), grace_seconds = 5)] //! impl Server { //! /// Start a counter server and return its process ID. //! async fn start(&self) -> Result { //! listen("/self/control", Rc::new(Control { hits: Cell::new(0) })).await?; //! Ok(pid()) //! } //! } //! #[rpc] //! impl Control { //! /// Record a request and return the new count. //! fn record(&self) -> Result { //! self.hits.set(self.hits.get() + 1); //! Ok(self.hits.get()) //! } //! /// Return the number of recorded requests. //! fn stats(&self) -> Result { Ok(self.hits.get()) } //! /// Stop this process after the client receives the response. //! fn stop(&self, ctx: &Ctx) -> Result<()> { //! ctx.exit_after_response(); //! Ok(()) //! } //! } //! fn main() { sausage_plugin::run(Server); } //! ``` //! //! # A service with a fixed endpoint //! //! A service can publish a fixed path in `/run`. The core clears `/run` when the host starts. //! Configure this service in `/config/boot.yaml` with the entry `- method: secrets.serve`. //! Clients can then connect to `/run/secrets`. //! //! ```no_run //! use sausage_plugin::{listen, rpc, schema, Result}; //! use std::rc::Rc; //! /// The service entry point. //! struct Secrets; //! /// The in-memory vault of this service process. //! struct Vault { token: String } //! /// The name of a secret. //! #[schema] //! struct GetParams { //! /// The secret to read. //! name: String, //! } //! #[rpc(endpoints(vault = Vault))] //! impl Secrets { //! /// Load the vault and publish its fixed endpoint. //! async fn serve(&self) -> Result<()> { //! let token = std::fs::read_to_string("/config/secret-token")?; //! listen("/run/secrets", Rc::new(Vault { token })).await //! } //! } //! #[rpc] //! impl Vault { //! /// Read the token, or return None for an unknown name. //! fn get(&self, p: GetParams) -> Result> { //! Ok((p.name == "token").then(|| self.token.clone())) //! } //! } //! fn main() { sausage_plugin::run(Secrets); } //! ``` //! //! [`connect`] reads the endpoint file and verifies its owner through `rpc.endpoint`. //! The handshake checks the plugin, owner process ID, and interface name. //! A generated endpoint client also requires its expected interface name. //! A mismatch returns -32000. Endpoint calls bypass a fresh core process. //! `connect` opens and closes a verification connection. Each call opens another connection and repeats the handshake. //! The first call costs three round trips: verification, another handshake, and the request. //! Later calls cost a handshake and a request each. The SDK does not pool connections. //! //! ```no_run //! use sausage_plugin::{connect, Result}; //! use sausage_plugin::serde_json::json; //! async fn read_token() -> Result> { //! let vault = connect("/run/secrets").await?; //! vault.require_interface("vault")?; //! vault.call("get", json!({"name":"token"})) //! .on_notify(|name, params| eprintln!("{name}: {params}")) //! .await //! } //! fn main() {} //! ``` //! //! # Import another plugin //! //! 1. Copy `/proc/core/plugins//discover.json` to `source/imports/.json` in your plugin folder. //! 2. Read `/proc/core/plugins//client.rs` for the exact generated client API. //! 3. Write `sausage_plugin::import!("")` and the code that calls it. //! 4. Build with `cargo.build`. Remove methods that rustc calls unused from the imported JSON document. //! 5. Build again after that change. Call `core.activate` with the resulting WASM path. //! //! The macro reads `imports/.json` relative to `CARGO_MANIFEST_DIR`, not the current folder. //! It generates a private module with typed [`TypedCall`] builders. //! The module retains schema type names and documentation. Await its functions to get their typed results. //! Builders support `detach()` and `on_notify(f)`. Generated endpoint modules provide typed `connect` functions. //! The macro tracks the JSON file with `include_str!`, so Cargo rebuilds after changes. //! It registers the imported contract for `rpc.discover` through inventory. //! Remove unused methods from the JSON, not from generated Rust. This keeps `x-sausage-imports` small. //! Retain the schemas that the remaining methods reference. //! //! This example imports the notes plugin above and a server with a `control` endpoint. //! The `www` provider exposes `start(StartParams { port })` and `control.stats()`. //! //! ```no_run //! use sausage_plugin::{rpc, Result}; //! sausage_plugin::import!("notes"); //! sausage_plugin::import!("www"); //! /// A consumer of notes and the HTTP server. //! struct Consumer; //! #[rpc] //! impl Consumer { //! /// Add a note, start a detached server, and return its request count. //! async fn example(&self) -> Result { //! let added = notes::add(notes::AddParams { text: "milk".into() }).await?; //! eprintln!("{} notes", added.count); //! let pid = www::start(www::StartParams { port: 8080 }).detach().await?; //! let control = www::control::connect(&format!("/proc/{pid}/control")).await?; //! control.stats().await //! } //! } //! fn main() { sausage_plugin::run(Consumer); } //! ``` //! //! # Tests in a world //! //! `cargo.build` runs tests under the WASI test runner. //! Both plain `#[test]` and async [`macro@test`] tests work. Each test gets its own instance and empty temporary world at `/`. //! The current folder is `/`, not the package folder. Read fixtures through `env!("CARGO_MANIFEST_DIR")`. //! `SAUSAGE_PLUGIN` is `test`, so `state_dir()` returns `/state/test`. //! Create directories before writes. `std::env::temp_dir()` does not work under this runner. //! Use an explicit path in the temporary world instead. //! The runner answers core lock, unlock, notify, and log requests. Calls to other plugins fail because no other plugins exist. //! Name filters, `tests/*.rs`, and `--ignored` work. `#[should_panic]` cannot work because WASI cannot catch panics. //! //! ```no_run //! use sausage_plugin::{lock, state_dir, write_atomic}; //! #[test] //! fn reads_package_fixture() { //! let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); //! assert!(std::fs::read_to_string(path).unwrap().contains("[package]")); //! } //! #[sausage_plugin::test] //! async fn writes_local_state() { //! let file = state_dir().join("test.txt"); //! std::fs::create_dir_all(state_dir()).unwrap(); //! let _guard = lock(&file).await.unwrap(); //! write_atomic(&file, b"saved").unwrap(); //! assert_eq!(std::fs::read(file).unwrap(), b"saved"); //! } //! fn main() {} //! ``` //! //! # Errors //! //! [`Error`] carries `code`, `message`, and optional structured `data`. //! `Error::new` constructs an error without data. Assign `data` when callers need structured details. //! Do not reduce an error to its message when another agent needs its code or data. //! //! | Code | Meaning | //! | --- | --- | //! | -32700 | Invalid JSON. | //! | -32600 | Invalid JSON-RPC request. | //! | -32601 | Unknown method. | //! | -32602 | Invalid method parameters. | //! | -32603 | Internal error, including converted I/O and serde errors. | //! | -32000 | Local SDK error: no transport, stdin EOF, endpoint closure, or identity or interface mismatch. | //! | -32001 | Core: no such plugin. | //! | -32002 | Core: the process ended without an answer. Data includes its exit status and journal tail. | //! | -32003 | Core: activation failed. Data describes the reasons. | //! //! Parameter decoding failures use -32602 instead of the general serde conversion code. //! //! ``` //! use sausage_plugin::Error; //! use sausage_plugin::serde_json::json; //! let mut error = Error::new(-32602, "unknown secret"); //! error.data = Some(json!({"name":"missing"})); //! assert_eq!(error.code, -32602); //! assert_eq!(error.data.unwrap()["name"], "missing"); //! ``` //! //! # State and upgrades //! //! The interface check covers calls, not state. Old and new processes share files in `/state`. //! `core.revert` restores code, not state. Preserve compatibility or migrate the state. //! Core gives every old process 5 seconds of upgrade grace, then closes stdin and allows //! a separate 5-second stop budget before killing it. Discovery-only processes are excluded. //! `#[rpc(grace_seconds = N)]` still emits legacy `x-sausage-grace` metadata, but core ignores it. //! New calls use the new version immediately. Old processes can still use old imports during their grace period. //! [`macro@on_superseded`] handles the notice at grace start, with normal calls still available. //! It is opt-in: without a hook the process continues as before until core stops it. //! Call [`drain`] in this hook to close SDK endpoint listeners and idle connections, //! let active handlers and background tasks finish, and exit without waiting for grace. //! Custom listeners and long-lived background tasks must be ended by the plugin itself. //! [`macro@on_retired`] handles `rpc.retired` after the last process of an old version ends. //! This hook is best effort. If the host stops first, the hook does not run. //! New processes must handle unmigrated state. Check a migration marker and migrate if it is absent. //! See section 3.7 of `/proc/core/PROTOCOL.md`. //! Declare at most one hook of each kind per interface. //! //! ```no_run //! use sausage_plugin::{rpc, schema, state_dir, write_atomic, Result}; //! /// A plugin that records retirement notices. //! struct Migrator; //! /// The version that no longer has running processes. //! #[schema] //! struct RetiredParams { //! /// The SHA-256 of the previous WASM file. //! previous: String, //! } //! /// The version now receiving new calls. //! #[schema] //! struct SupersededParams { //! /// The SHA-256 of the newly active WASM file. //! current: String, //! } //! #[rpc] //! impl Migrator { //! /// Finish current work and exit early during upgrade grace. //! #[sausage_plugin::on_superseded] //! fn superseded(&self, p: SupersededParams) -> Result<()> { //! eprintln!("replaced by {}", p.current); //! sausage_plugin::drain(); //! Ok(()) //! } //! /// Record a retired version. Normal methods must also handle missing markers. //! #[sausage_plugin::on_retired] //! fn retired(&self, p: RetiredParams) -> Result<()> { //! std::fs::create_dir_all(state_dir())?; //! write_atomic(state_dir().join("retired-version"), p.previous.as_bytes()) //! } //! } //! fn main() { sausage_plugin::run(Migrator); } //! ``` #![warn(missing_docs)] mod discovery; mod endpoints; mod error; mod helpers; mod imports; mod lines; mod sqlite; mod transport; pub use discovery::*; pub use endpoints::*; pub use error::*; pub use helpers::*; pub use imports::*; #[doc(hidden)] pub use inventory; pub use sausage_plugin_macros::{import, on_retired, on_stop, on_superseded, rpc, schema, test}; pub use transport::*; pub use {schemars, serde, serde_json};