feat: initial fai-module-sdk v0.1.0 with #[fai_module] macro
All checks were successful
CI / Linux x86_64 (Forgejo) (push) Successful in 1m41s

The SDK provides a stable, ergonomic Rust surface for writing
F∆I Platform modules. A module is now a single decorated
function:

    #[fai_module]
    fn invoke(_ctx: Context, inputs: Inputs)
        -> Result<Outputs, ModuleError>
    {
        let text = inputs.require_text("input")?;
        Ok(Outputs::new().with_text("output", format!("echo: {text}")))
    }

The macro absorbs every piece of WASM-module boilerplate that
previously lived per-repo: the wit_bindgen::generate! call, the
Guest trait impl, the WIT-to-SDK type conversions, the
wit_bindgen::export! glue, and the unsafe_op_in_unsafe_fn allow
attribute. Module repos depend only on fai-module-sdk and need
no wit/ directory of their own — the SDK ships a verified
mirror of the platform's frozen v1.0 WIT contract.

The SDK is the central lever for keeping the contract stable
without freezing module ergonomics. If the platform ever bumps
the WIT contract (additive minor or coordinated v2.0), the
change is absorbed inside the SDK; existing module source
keeps compiling against a new SDK release.

Components in this initial drop:

  crates/fai-module-sdk
      Public types — Context, Inputs, Outputs, Payload,
      ModuleError. Helpers like require_text / require_json /
      with_bytes / with_json. Pure Rust, no wit_bindgen
      dependency in user-facing code.

  crates/fai-module-sdk-macros
      The proc-macro crate that emits the wit_bindgen call,
      the Guest impl, the WIT<->SDK conversion code, and the
      export! invocation. Uses runtime_path to route the
      generated bindings through the SDK's re-export of
      wit_bindgen::rt so user crates need no direct dep.

  wit/world.wit
      Mirror of fai/platform :: wit/world.wit, frozen at v1.0.
      A snapshot test (crates/fai-module-sdk/tests/wit_freeze.rs)
      asserts the SHA-256 matches the platform constant; CI
      fails if the two ever drift.

  examples/echo
      Reference module that demonstrates the entire authoring
      surface. Compiles to wasm32-wasip2 with the correct
      v1.0 imports baked in (verified manually; CI runs the
      same build).

Tests: 7 unit tests + 1 snapshot test. Cargo fmt and clippy
clean across the workspace and the example.

Forgejo CI workflow mirrors the platform pattern (manual
external-URL checkout + libssl-dev system deps + wasm32-wasip2
target + workspace fmt/clippy/build/test + example wasm build).

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-05-01 02:23:44 +02:00
commit d3642853ec
22 changed files with 2095 additions and 0 deletions

View file

@ -0,0 +1,23 @@
[package]
name = "fai-module-sdk"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
rust-version.workspace = true
description = "Stable, ergonomic surface for writing F∆I Platform modules in Rust"
[dependencies]
fai-module-sdk-macros = { path = "../fai-module-sdk-macros", version = "=0.1.0" }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
wit-bindgen = { workspace = true }
[dev-dependencies]
sha2 = { workspace = true }
[lints]
workspace = true

View file

@ -0,0 +1,13 @@
//! Invocation context passed to a module on every call.
//!
//! Mirrors the WIT `invocation-context` record. The
//! `#[fai_module]` macro builds this from the WIT-generated value
//! before handing it to user code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Context {
pub invocation_id: String,
pub capability_namespace: String,
pub capability_name: String,
pub deadline_ms: u64,
}

View file

@ -0,0 +1,49 @@
//! Module error type — mirrors the WIT `invocation-error` variant.
use thiserror::Error;
/// Error a module returns when invocation fails.
///
/// The variants match the WIT `invocation-error` cases one-to-one,
/// so a module's structured failure is preserved over the wire.
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum ModuleError {
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("resource exhausted: {0}")]
ResourceExhausted(String),
#[error("deadline exceeded")]
DeadlineExceeded,
#[error("internal error: {0}")]
Internal(String),
}
impl ModuleError {
pub fn invalid_input(message: impl Into<String>) -> Self {
ModuleError::InvalidInput(message.into())
}
pub fn permission_denied(message: impl Into<String>) -> Self {
ModuleError::PermissionDenied(message.into())
}
pub fn resource_exhausted(message: impl Into<String>) -> Self {
ModuleError::ResourceExhausted(message.into())
}
pub fn internal(message: impl Into<String>) -> Self {
ModuleError::Internal(message.into())
}
}
impl From<serde_json::Error> for ModuleError {
fn from(value: serde_json::Error) -> Self {
ModuleError::InvalidInput(format!("invalid JSON: {value}"))
}
}

View file

@ -0,0 +1,170 @@
//! Typed access to the inputs a module receives.
use serde::de::DeserializeOwned;
use crate::error::ModuleError;
use crate::payload::{BytesValue, FileRef, Payload};
/// The inputs a module receives, keyed by name as declared in the
/// flow YAML.
///
/// The `require_*` accessors validate both presence and variant in
/// one step, returning a clear `InvalidInput` error if either check
/// fails. Module authors should prefer them over `get` + manual
/// matching.
#[derive(Debug, Default)]
pub struct Inputs {
items: Vec<(String, Payload)>,
}
impl Inputs {
/// Build directly from a list of `(name, payload)` pairs.
/// Used by the `#[fai_module]` macro after WIT conversion.
#[doc(hidden)]
pub fn __from_pairs(items: Vec<(String, Payload)>) -> Self {
Self { items }
}
/// Build from a list of pairs — convenient for tests.
pub fn from_pairs<I, K, V>(pairs: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<Payload>,
{
Self {
items: pairs
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
}
}
/// Number of inputs.
pub fn len(&self) -> usize {
self.items.len()
}
/// `true` if there are no inputs.
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
/// Iterate over `(name, payload)` pairs.
pub fn iter(&self) -> impl Iterator<Item = (&str, &Payload)> {
self.items.iter().map(|(k, v)| (k.as_str(), v))
}
/// Look up a payload by name, returning `None` if absent.
pub fn get(&self, name: &str) -> Option<&Payload> {
self.items
.iter()
.find_map(|(k, v)| if k == name { Some(v) } else { None })
}
/// Require a `Text` input under `name`. Errors if missing or wrong variant.
pub fn require_text(&self, name: &str) -> Result<&str, ModuleError> {
match self.require(name)? {
Payload::Text(s) => Ok(s.as_str()),
other => Err(wrong_variant(name, "text", other)),
}
}
/// Require a `Bytes` input under `name`. Returns `(mime, data)`.
pub fn require_bytes(&self, name: &str) -> Result<&BytesValue, ModuleError> {
match self.require(name)? {
Payload::Bytes(b) => Ok(b),
other => Err(wrong_variant(name, "bytes", other)),
}
}
/// Require a `Json` input under `name`, returned as the raw JSON string.
pub fn require_json_str(&self, name: &str) -> Result<&str, ModuleError> {
match self.require(name)? {
Payload::Json(s) => Ok(s.as_str()),
other => Err(wrong_variant(name, "json", other)),
}
}
/// Require a `Json` input under `name`, parsed as `T`.
pub fn require_json<T: DeserializeOwned>(&self, name: &str) -> Result<T, ModuleError> {
let raw = self.require_json_str(name)?;
serde_json::from_str(raw).map_err(ModuleError::from)
}
/// Require a `FileRef` input under `name`.
pub fn require_file_ref(&self, name: &str) -> Result<&FileRef, ModuleError> {
match self.require(name)? {
Payload::FileRef(f) => Ok(f),
other => Err(wrong_variant(name, "file-ref", other)),
}
}
fn require(&self, name: &str) -> Result<&Payload, ModuleError> {
self.get(name)
.ok_or_else(|| ModuleError::InvalidInput(format!("missing input: {name}")))
}
}
fn wrong_variant(name: &str, expected: &str, got: &Payload) -> ModuleError {
ModuleError::InvalidInput(format!(
"input '{name}' has wrong type: expected {expected}, got {got}",
got = got.type_name(),
))
}
impl From<String> for Payload {
fn from(value: String) -> Self {
Payload::Text(value)
}
}
impl From<&str> for Payload {
fn from(value: &str) -> Self {
Payload::Text(value.to_string())
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn require_text_ok() {
let i = Inputs::from_pairs([("greeting", "hello")]);
assert_eq!(i.require_text("greeting").unwrap(), "hello");
}
#[test]
fn require_text_missing_is_invalid_input() {
let i = Inputs::default();
let err = i.require_text("greeting").unwrap_err();
assert!(matches!(err, ModuleError::InvalidInput(_)));
assert!(format!("{err}").contains("missing input"));
}
#[test]
fn require_text_wrong_variant_is_invalid_input() {
let i = Inputs::from_pairs([("blob", Payload::bytes("text/plain", b"data".to_vec()))]);
let err = i.require_text("blob").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("expected text"), "msg = {msg}");
assert!(msg.contains("got bytes"), "msg = {msg}");
}
#[test]
fn require_json_parses_into_target() {
#[derive(serde::Deserialize, PartialEq, Debug)]
struct Goal {
text: String,
}
let i = Inputs::from_pairs([("goal", Payload::Json(r#"{"text":"hi"}"#.to_string()))]);
assert_eq!(
i.require_json::<Goal>("goal").unwrap(),
Goal {
text: "hi".to_string()
}
);
}
}

View file

@ -0,0 +1,55 @@
//! Ergonomic Rust surface for writing F∆I Platform modules.
//!
//! A module written against this crate looks like:
//!
//! ```ignore
//! use fai_module_sdk::prelude::*;
//!
//! #[fai_module]
//! fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
//! let text = inputs.require_text("input")?;
//! Ok(Outputs::new().with_text("output", format!("got: {text}")))
//! }
//! ```
//!
//! The macro generates the WIT bindings, the `Guest` impl, and the
//! `wit_bindgen::export!` invocation behind the scenes. Module authors
//! never touch `wit_bindgen` directly.
//!
//! # Stability
//!
//! This crate insulates modules from the underlying WIT contract
//! (`fai:platform`, frozen at v1.0). The WIT may evolve in additive
//! minor versions or, eventually, a coordinated v2.0; the SDK absorbs
//! that change so module code keeps compiling.
mod context;
mod error;
mod inputs;
mod outputs;
mod payload;
pub use context::Context;
pub use error::ModuleError;
pub use inputs::Inputs;
pub use outputs::Outputs;
pub use payload::{BytesValue, FileRef, Payload};
pub use fai_module_sdk_macros::fai_module;
/// Convenience prelude — `use fai_module_sdk::prelude::*;` brings
/// every type a typical module needs into scope.
pub mod prelude {
pub use super::{
BytesValue, Context, FileRef, Inputs, ModuleError, Outputs, Payload, fai_module,
};
}
/// Internal re-exports the `#[fai_module]` macro expansion depends on.
///
/// Not part of the public API. Anything in this module may change
/// between SDK patch releases.
#[doc(hidden)]
pub mod __rt {
pub use wit_bindgen;
}

View file

@ -0,0 +1,119 @@
//! Builder for the outputs a module returns.
use serde::Serialize;
use crate::error::ModuleError;
use crate::payload::Payload;
/// Output values a module produces, keyed by name as expected by
/// the calling flow step.
#[derive(Debug, Default)]
pub struct Outputs {
items: Vec<(String, Payload)>,
}
impl Outputs {
pub fn new() -> Self {
Self::default()
}
pub fn with_text(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.items.push((name.into(), Payload::Text(value.into())));
self
}
pub fn with_json_str(mut self, name: impl Into<String>, raw: impl Into<String>) -> Self {
self.items.push((name.into(), Payload::Json(raw.into())));
self
}
/// Serialize `value` as JSON and add it under `name`. Errors are
/// returned as `ModuleError::Internal` since serialization of a
/// well-typed value should not fail under normal conditions.
pub fn with_json<T: Serialize>(
mut self,
name: impl Into<String>,
value: &T,
) -> Result<Self, ModuleError> {
let raw = serde_json::to_string(value).map_err(|e| {
ModuleError::Internal(format!("failed to serialize output as JSON: {e}"))
})?;
self.items.push((name.into(), Payload::Json(raw)));
Ok(self)
}
pub fn with_bytes(
mut self,
name: impl Into<String>,
mime_type: impl Into<String>,
data: impl Into<Vec<u8>>,
) -> Self {
self.items
.push((name.into(), Payload::bytes(mime_type, data)));
self
}
pub fn with_payload(mut self, name: impl Into<String>, payload: Payload) -> Self {
self.items.push((name.into(), payload));
self
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &Payload)> {
self.items.iter().map(|(k, v)| (k.as_str(), v))
}
/// Consume the builder into the underlying `(name, payload)` list.
/// Used by the `#[fai_module]` macro after WIT conversion.
#[doc(hidden)]
pub fn __into_pairs(self) -> Vec<(String, Payload)> {
self.items
}
/// Public consume for tests / advanced callers.
pub fn into_pairs(self) -> Vec<(String, Payload)> {
self.items
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
#[test]
fn fluent_builder_collects_items_in_order() {
let out = Outputs::new().with_text("greeting", "hi").with_bytes(
"blob",
"application/octet-stream",
vec![1u8, 2, 3],
);
let pairs = out.into_pairs();
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0].0, "greeting");
assert!(matches!(pairs[1].1, Payload::Bytes(_)));
}
#[test]
fn with_json_serializes_typed_value() {
#[derive(serde::Serialize)]
struct Reply {
ok: bool,
}
let out = Outputs::new()
.with_json("reply", &Reply { ok: true })
.unwrap();
let pairs = out.into_pairs();
match &pairs[0].1 {
Payload::Json(s) => assert!(s.contains(r#""ok":true"#)),
other => panic!("expected Json, got {other:?}"),
}
}
}

View file

@ -0,0 +1,76 @@
//! Module-side mirror of the WIT `payload` variant.
//!
//! The shape is fixed by the v1.0 WIT freeze. Field-by-field
//! conversion to and from the WIT-generated type happens inside
//! the `#[fai_module]` macro expansion in the user crate.
use serde::{Deserialize, Serialize};
/// A typed value flowing between the host and a module.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Payload {
/// Plain UTF-8 text.
Text(String),
/// JSON encoded as a string.
Json(String),
/// Raw bytes with a MIME type.
Bytes(BytesValue),
/// File reference by URI.
FileRef(FileRef),
}
/// Inline byte content with a MIME type.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BytesValue {
pub mime_type: String,
pub data: Vec<u8>,
}
/// File reference by URI plus integrity metadata.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileRef {
pub uri: String,
pub mime_type: String,
pub size_bytes: u64,
pub sha256: String,
}
impl Payload {
pub fn text(value: impl Into<String>) -> Self {
Payload::Text(value.into())
}
pub fn json(value: impl Into<String>) -> Self {
Payload::Json(value.into())
}
pub fn bytes(mime_type: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
Payload::Bytes(BytesValue {
mime_type: mime_type.into(),
data: data.into(),
})
}
pub fn file_ref(
uri: impl Into<String>,
mime_type: impl Into<String>,
size_bytes: u64,
sha256: impl Into<String>,
) -> Self {
Payload::FileRef(FileRef {
uri: uri.into(),
mime_type: mime_type.into(),
size_bytes,
sha256: sha256.into(),
})
}
pub fn type_name(&self) -> &'static str {
match self {
Payload::Text(_) => "text",
Payload::Json(_) => "json",
Payload::Bytes(_) => "bytes",
Payload::FileRef(_) => "file-ref",
}
}
}

View file

@ -0,0 +1,53 @@
//! Snapshot test for the WIT mirror.
//!
//! The SDK ships its own copy of `wit/world.wit` so the proc-macro
//! can embed it via `include_str!` at compile time. The hash MUST
//! match the platform's frozen v1.0 hash. If this test fails, the
//! mirror has drifted and the SDK cannot guarantee binary
//! compatibility with the platform.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
use sha2::{Digest, Sha256};
/// Identical to FROZEN_WIT_SHA256 in
/// fai/platform :: crates/fai_runtime/tests/wit_freeze.rs.
/// Update both repos in lockstep, never one alone.
const PLATFORM_FROZEN_WIT_SHA256: &str =
"6f4e2266a4264832a16fe5a3704622cd978b290b90f3300c3ed236d98b689b5f";
fn world_wit_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("crates parent")
.parent()
.expect("workspace root")
.join("wit")
.join("world.wit")
}
#[test]
fn wit_mirror_matches_platform_frozen_hash() {
let path = world_wit_path();
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let mut hasher = Sha256::new();
hasher.update(&bytes);
let actual = format!("{:x}", hasher.finalize());
assert_eq!(
actual, PLATFORM_FROZEN_WIT_SHA256,
"\n\nwit/world.wit in this repo has drifted from the platform's\n\
frozen v1.0 hash. The SDK MUST mirror the platform exactly\n\
or modules built with this SDK will not be wire-compatible.\n\n\
If the platform intentionally bumped to a new minor (e.g.\n\
v1.1 with additive changes), update BOTH:\n\
1. wit/world.wit here to match the platform copy\n\
2. PLATFORM_FROZEN_WIT_SHA256 in this test\n\n\
If the platform is still on the old hash, revert this WIT\n\
change.\n\n\
actual hash: {actual}\n"
);
}