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

73
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,73 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
RUST_TOOLCHAIN: "1.86"
jobs:
ci:
name: Linux x86_64 (Forgejo)
runs-on: ubuntu-latest
steps:
# See fai/platform docs/architecture/ci.md for why we
# check out via the external URL instead of using
# actions/checkout.
- name: Checkout via external URL
run: |
set -eu
mkdir -p "$GITHUB_WORKSPACE"
cd "$GITHUB_WORKSPACE"
git init -q
git remote add origin \
"https://x-access-token:${GITHUB_TOKEN}@git.flemming.ws/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "$GITHUB_SHA"
git checkout -q FETCH_HEAD
- name: Install system dependencies
run: |
apt-get update -qq
apt-get install -y --no-install-recommends \
curl \
ca-certificates \
build-essential \
pkg-config \
libssl-dev \
git
- name: Install Rust toolchain
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y \
--profile minimal \
--default-toolchain "$RUST_TOOLCHAIN" \
--target wasm32-wasip2 \
--component rustfmt \
--component clippy
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Cargo fmt --check (workspace)
run: cargo fmt --all -- --check
- name: Cargo fmt --check (echo example)
working-directory: examples/echo
run: cargo fmt --all -- --check
- name: Cargo clippy (workspace)
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Cargo build (workspace)
run: cargo build --workspace --all-targets
- name: Cargo test (workspace)
run: cargo test --workspace --all-targets
- name: Cargo build echo example to wasm32-wasip2
working-directory: examples/echo
run: cargo build --release --target wasm32-wasip2

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
target/
**/*.rs.bk
Cargo.lock.bak
.DS_Store

504
Cargo.lock generated Normal file
View file

@ -0,0 +1,504 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "fai-module-sdk"
version = "0.1.0"
dependencies = [
"fai-module-sdk-macros",
"serde",
"serde_json",
"sha2",
"thiserror",
"wit-bindgen",
]
[[package]]
name = "fai-module-sdk-macros"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.0",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "spdx"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
dependencies = [
"smallvec",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "typenum"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-encoder"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1"
dependencies = [
"leb128",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7"
dependencies = [
"anyhow",
"indexmap",
"serde",
"serde_derive",
"serde_json",
"spdx",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
dependencies = [
"ahash",
"bitflags",
"hashbrown 0.14.5",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3"
dependencies = [
"wit-bindgen-rt",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea"
dependencies = [
"bitflags",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

37
Cargo.toml Normal file
View file

@ -0,0 +1,37 @@
[workspace]
resolver = "2"
members = [
"crates/fai-module-sdk",
"crates/fai-module-sdk-macros",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
authors = ["Dr. Stefan Flemming <platform@flemming.ai>"]
license = "Apache-2.0"
repository = "https://git.flemming.ws/fai/module-sdk"
homepage = "https://flemming.ai/"
rust-version = "1.85"
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "warn"
[workspace.lints.clippy]
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
print_stdout = "deny"
print_stderr = "deny"
todo = "deny"
unimplemented = "deny"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
wit-bindgen = "0.36"
sha2 = "0.10"

73
LICENSE Normal file
View file

@ -0,0 +1,73 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2026 fai-modules
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

16
NOTICE Normal file
View file

@ -0,0 +1,16 @@
fai-module-sdk
Copyright 2026 Flemming.AI
This product includes software developed by Dr. Stefan Flemming
and contributors (https://flemming.ai/).
This SDK ships a copy of the WIT contract owned by F∆I Platform
(`fai/platform`, frozen at v1.0). The mirror is verified by a
SHA-256 snapshot test and updated only in lockstep with the
upstream contract.
------------------------------------------------------------------
Third-party acknowledgments
Runtime dependencies and their licenses are documented in the
Software Bill of Materials (SBOM) generated on every release.

59
README.md Normal file
View file

@ -0,0 +1,59 @@
# fai-module-sdk
Stable, ergonomic Rust surface for writing F∆I Platform modules.
A module written against this crate looks like:
```rust
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!("echo: {text}")))
}
```
That single function — plus a `Cargo.toml` listing
`fai-module-sdk` as a dependency — is the entire module. The
`#[fai_module]` macro generates the `wit_bindgen` invocation, the
`Guest` implementation, the type conversions, and the
`wit_bindgen::export!` glue behind the scenes.
## What the SDK gives you
| You get | Instead of |
|---------|-----------|
| `Inputs::require_text("name")` | manual `match payload { Payload::Text(s) => ..., _ => ... }` |
| `Outputs::new().with_json(...)?` | building `Vec<(String, Payload)>` by hand |
| `ModuleError::invalid_input("...")` | constructing WIT `invocation-error` variants |
| `#[fai_module]` | `wit_bindgen::generate!` + `Guest` impl + `export!` + `#![allow(unsafe_op_in_unsafe_fn)]` |
## Stability
The SDK insulates module code from the underlying WIT contract
(`fai:platform`, frozen at v1.0 in the platform repo). The
WIT may evolve in additive minor versions or, eventually, a
coordinated v2.0; the SDK absorbs that change so existing
module code keeps compiling.
The SDK ships its own copy of `wit/world.wit` so that the
proc-macro can embed the contract via `include_str!` at compile
time. A snapshot test (`crates/fai-module-sdk/tests/wit_freeze.rs`)
asserts the SHA-256 matches the platform's frozen hash — if the
two ever drift, CI fails.
## Versioning
| Track | Stability |
|-------|-----------|
| WIT contract (`fai:platform`) | Frozen at v1.0 in the platform repo |
| `fai-module-sdk` Rust API | Semver; v0.x while ergonomics evolve |
| Per-module versions | Each module repo's own concern |
## License
Apache-2.0. See `LICENSE`.
Author: Dr. Stefan Flemming, Flemming.AI <platform@flemming.ai>
Repository: https://git.flemming.ws/fai/module-sdk

View file

@ -0,0 +1,21 @@
[package]
name = "fai-module-sdk-macros"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
rust-version.workspace = true
description = "Procedural macros for fai-module-sdk. Not used directly — see fai-module-sdk."
[lib]
proc-macro = true
[dependencies]
proc-macro2 = { workspace = true }
quote = { workspace = true }
syn = { workspace = true }
[lints]
workspace = true

View file

@ -0,0 +1,167 @@
//! Procedural macros powering `fai-module-sdk`.
//!
//! This crate exists only to host the `#[fai_module]` attribute.
//! Module authors should depend on `fai-module-sdk` (which
//! re-exports the macro), not on this crate directly.
use proc_macro::TokenStream;
use quote::quote;
use syn::{ItemFn, parse_macro_input};
/// The frozen WIT contract, embedded at proc-macro compile time.
/// The platform repo and this repo both ship a SHA-256 snapshot
/// test that fails if these copies drift.
const WIT_INLINE: &str = include_str!("../../../wit/world.wit");
/// Wrap a function with all the WASM module boilerplate.
///
/// The annotated function must have the signature:
///
/// ```ignore
/// fn invoke(ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError>
/// ```
///
/// The macro generates the `wit_bindgen::generate!` invocation,
/// the `Guest` implementation, the WIT-to-SDK conversions, and
/// the `wit_bindgen::export!` call. The function can be named
/// anything — only its signature matters.
#[proc_macro_attribute]
pub fn fai_module(_attrs: TokenStream, input: TokenStream) -> TokenStream {
let user_fn = parse_macro_input!(input as ItemFn);
let user_fn_name = user_fn.sig.ident.clone();
let wit_literal = WIT_INLINE;
let expanded = quote! {
#user_fn
#[doc(hidden)]
mod __fai_module_sdk_internal {
#![allow(
unsafe_op_in_unsafe_fn,
unused_imports,
dead_code,
clippy::all,
clippy::pedantic,
clippy::nursery
)]
::fai_module_sdk::__rt::wit_bindgen::generate!({
inline: #wit_literal,
world: "module",
runtime_path: "::fai_module_sdk::__rt::wit_bindgen::rt",
});
use exports::fai::platform::invoke::Guest;
use fai::platform::types::{
BytesValue as WitBytes,
FileRef as WitFileRef,
InvocationContext as WitContext,
InvocationError as WitError,
Payload as WitPayload,
};
struct Component;
impl Guest for Component {
fn invoke(
ctx: WitContext,
inputs: ::std::vec::Vec<(
::std::string::String,
WitPayload,
)>,
) -> ::core::result::Result<
::std::vec::Vec<(::std::string::String, WitPayload)>,
WitError,
> {
let sdk_ctx = ::fai_module_sdk::Context {
invocation_id: ctx.invocation_id,
capability_namespace: ctx.capability_namespace,
capability_name: ctx.capability_name,
deadline_ms: ctx.deadline_ms,
};
let sdk_pairs: ::std::vec::Vec<(
::std::string::String,
::fai_module_sdk::Payload,
)> = inputs
.into_iter()
.map(|(k, p)| (k, wit_payload_to_sdk(p)))
.collect();
let sdk_inputs =
::fai_module_sdk::Inputs::__from_pairs(sdk_pairs);
match super::#user_fn_name(sdk_ctx, sdk_inputs) {
Ok(outputs) => {
let pairs = outputs.__into_pairs();
let wit_pairs = pairs
.into_iter()
.map(|(k, p)| (k, sdk_payload_to_wit(p)))
.collect();
Ok(wit_pairs)
}
Err(e) => Err(sdk_error_to_wit(e)),
}
}
}
fn wit_payload_to_sdk(p: WitPayload) -> ::fai_module_sdk::Payload {
match p {
WitPayload::Text(s) => ::fai_module_sdk::Payload::Text(s),
WitPayload::Json(s) => ::fai_module_sdk::Payload::Json(s),
WitPayload::Bytes(b) => ::fai_module_sdk::Payload::Bytes(
::fai_module_sdk::BytesValue {
mime_type: b.mime_type,
data: b.data,
},
),
WitPayload::FileRef(f) => ::fai_module_sdk::Payload::FileRef(
::fai_module_sdk::FileRef {
uri: f.uri,
mime_type: f.mime_type,
size_bytes: f.size_bytes,
sha256: f.sha256,
},
),
}
}
fn sdk_payload_to_wit(p: ::fai_module_sdk::Payload) -> WitPayload {
match p {
::fai_module_sdk::Payload::Text(s) => WitPayload::Text(s),
::fai_module_sdk::Payload::Json(s) => WitPayload::Json(s),
::fai_module_sdk::Payload::Bytes(b) => WitPayload::Bytes(WitBytes {
mime_type: b.mime_type,
data: b.data,
}),
::fai_module_sdk::Payload::FileRef(f) => WitPayload::FileRef(WitFileRef {
uri: f.uri,
mime_type: f.mime_type,
size_bytes: f.size_bytes,
sha256: f.sha256,
}),
}
}
fn sdk_error_to_wit(e: ::fai_module_sdk::ModuleError) -> WitError {
match e {
::fai_module_sdk::ModuleError::InvalidInput(s) => {
WitError::InvalidInput(s)
}
::fai_module_sdk::ModuleError::PermissionDenied(s) => {
WitError::PermissionDenied(s)
}
::fai_module_sdk::ModuleError::ResourceExhausted(s) => {
WitError::ResourceExhausted(s)
}
::fai_module_sdk::ModuleError::DeadlineExceeded => {
WitError::DeadlineExceeded
}
::fai_module_sdk::ModuleError::Internal(s) => WitError::Internal(s),
}
}
export!(Component);
}
};
TokenStream::from(expanded)
}

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"
);
}

439
examples/echo/Cargo.lock generated Normal file
View file

@ -0,0 +1,439 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "echo_example"
version = "0.1.0"
dependencies = [
"fai-module-sdk",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "fai-module-sdk"
version = "0.1.0"
dependencies = [
"fai-module-sdk-macros",
"serde",
"serde_json",
"thiserror",
"wit-bindgen",
]
[[package]]
name = "fai-module-sdk-macros"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.0",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "spdx"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
dependencies = [
"smallvec",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-encoder"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1"
dependencies = [
"leb128",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7"
dependencies = [
"anyhow",
"indexmap",
"serde",
"serde_derive",
"serde_json",
"spdx",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
dependencies = [
"ahash",
"bitflags",
"hashbrown 0.14.5",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3"
dependencies = [
"wit-bindgen-rt",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea"
dependencies = [
"bitflags",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

28
examples/echo/Cargo.toml Normal file
View file

@ -0,0 +1,28 @@
# Standalone example — NOT a workspace member.
#
# Build with:
# cd examples/echo
# cargo build --release --target wasm32-wasip2
[package]
name = "echo_example"
version = "0.1.0"
edition = "2024"
license = "Apache-2.0"
publish = false
description = "Reference module showing the minimum needed to write an F∆I module with fai-module-sdk."
[lib]
crate-type = ["cdylib"]
[dependencies]
fai-module-sdk = { path = "../../crates/fai-module-sdk" }
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
strip = "symbols"
[workspace]
# Empty workspace table so this package is NOT picked up by a parent.

14
examples/echo/src/lib.rs Normal file
View file

@ -0,0 +1,14 @@
//! Minimum-viable F∆I module — echoes the first text input.
//!
//! Demonstrates the entire authoring surface: a single function
//! decorated with `#[fai_module]`. No `wit_bindgen`, no
//! `unsafe_op_in_unsafe_fn` allow, no `export!` call, no manual
//! Payload-variant matching.
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!("echo: {text}")))
}

3
rust-toolchain.toml Normal file
View file

@ -0,0 +1,3 @@
[toolchain]
channel = "1.86"
components = ["rustfmt", "clippy"]

99
wit/world.wit Normal file
View file

@ -0,0 +1,99 @@
// =============================================================
// FROZEN. Wire contract between hub and modules.
// =============================================================
//
// This file is the stable wire contract between the F∆I Hub
// and any module compiled against it. It is **frozen at v1.0**.
//
// Frozen means:
//
// - No removal, rename, or signature change of any existing
// interface, function, type, field, or variant case.
// - Additive changes (new types, new variant cases at the end,
// new fields with stable defaults) require a minor bump
// (1.0 -> 1.1) and a coordinated review.
// - Any breaking change requires a major bump (1.0 -> 2.0)
// and a parallel-world transition plan.
//
// The fai_runtime crate ships a snapshot test that fails if
// this file changes without an intentional, reviewed update.
// See `crates/fai_runtime/tests/wit_freeze.rs`.
//
// Module authors do NOT depend on this file directly. They
// use `fai-module-sdk`, which wraps these bindings behind a
// stable, ergonomic surface. That is what protects modules
// from any future evolution of this contract.
package fai:platform@1.0.0;
/// Types that flow between the host (hub) and modules.
interface types {
/// A typed value passed between the host and a module.
variant payload {
/// Plain UTF-8 text.
text(string),
/// Arbitrary JSON encoded as a string.
json(string),
/// Raw bytes with a MIME type.
bytes(bytes-value),
/// A reference to a file, by URI.
file-ref(file-ref),
}
/// Inline byte content.
record bytes-value {
mime-type: string,
data: list<u8>,
}
/// File reference by URI.
record file-ref {
uri: string,
mime-type: string,
size-bytes: u64,
sha256: string,
}
/// Context passed with every invocation.
record invocation-context {
invocation-id: string,
capability-namespace: string,
capability-name: string,
deadline-ms: u64,
}
/// Structured error type returned by modules.
variant invocation-error {
invalid-input(string),
permission-denied(string),
resource-exhausted(string),
deadline-exceeded,
internal(string),
}
}
/// The host-provided interface that modules can import to talk back.
interface host {
/// Log a structured message. Level is "trace", "debug", "info", "warn", "error".
log: func(level: string, message: string);
}
/// The core interface every module exports.
interface invoke {
use types.{payload, invocation-context, invocation-error};
/// Invoke the module with inputs, receive outputs or an error.
invoke: func(
ctx: invocation-context,
inputs: list<tuple<string, payload>>,
) -> result<list<tuple<string, payload>>, invocation-error>;
}
/// The world a module component targets.
///
/// Modules `export` the invoke interface to be callable by the hub.
/// Modules `import` the host interface to emit logs.
world module {
import host;
export invoke;
}