feat(sdk): Context::emit for WIT 1.1 host.emit-event (T1)
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 1s

Brings the module SDK to WIT 1.1 in lockstep with the platform and
exposes the new host.emit-event as an ergonomic ctx.emit(name,
payload). Modules emit ephemeral progress/stream events (progress,
token streaming) delivered live to a following client and never
persisted.

- wit/world.wit → 1.1.0 (host.emit-event + use types.{payload}).
- host.rs: an emit hook (thread-local fn pointer; guest is
  single-threaded). The #[fai_module] expansion installs a closure
  reaching the real WIT emit-event import at the start of each
  invocation; Context::emit calls through it. Outside a hub (unit
  tests) it is a silent no-op.
- examples/progress: reference module emitting progress + token events;
  builds clean for wasm32-wasip2, proving the ergonomics end-to-end.

The SDK keeps shielding module authors: emit is the whole surface, no
wit_bindgen in sight.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-07-05 19:22:53 +02:00
parent 98e98e9371
commit f32dc820b9
9 changed files with 568 additions and 5 deletions

View file

@ -11,3 +11,23 @@ pub struct Context {
pub capability_name: String,
pub deadline_ms: u64,
}
impl Context {
/// Emit an ephemeral progress/stream event during this invocation
/// (WIT 1.1 `emit-event`). Delivered live to any client following
/// the flow (`SubmitStream`); it is **never** written to the audit
/// log. Use it for progress updates or token streaming.
///
/// `name` is a caller-defined label (e.g. `"progress"`, `"token"`);
/// `payload` is the event body. The host enforces per-invocation
/// size + rate limits — an over-limit event is dropped, never an
/// error. When the module runs outside a hub that supports it (or
/// in a unit test) `emit` is a silent no-op.
///
/// ```ignore
/// ctx.emit("progress", Payload::Text("50%".into()));
/// ```
pub fn emit(&self, name: &str, payload: crate::Payload) {
crate::emit(name, payload);
}
}

View file

@ -0,0 +1,35 @@
//! Host-callback plumbing for `Context::emit` (WIT 1.1 `emit-event`).
//!
//! The WIT host imports (`emit-event`) are only reachable from the
//! `wit_bindgen`-generated glue inside the `#[fai_module]` expansion,
//! which lives in the module crate — not here. So the macro installs a
//! function pointer that reaches the real host import, and
//! [`Context::emit`](crate::Context::emit) calls it through this hook.
//!
//! On a non-wasm host target (unit tests) no hook is installed, so
//! `emit` is a silent no-op. The guest is single-threaded, so a
//! thread-local cell is sufficient and lock-free.
use std::cell::Cell;
use crate::Payload;
thread_local! {
static EMIT_HOOK: Cell<Option<fn(&str, Payload)>> = const { Cell::new(None) };
}
/// Install the emit hook. Called by the `#[fai_module]` expansion at
/// the start of each invocation. Not part of the stable public API.
#[doc(hidden)]
pub fn __set_emit_hook(f: fn(&str, Payload)) {
EMIT_HOOK.with(|c| c.set(Some(f)));
}
/// Forward an event to the host, if a hook is installed.
pub(crate) fn emit(name: &str, payload: Payload) {
EMIT_HOOK.with(|c| {
if let Some(f) = c.get() {
f(name, payload);
}
});
}

View file

@ -25,6 +25,7 @@
mod context;
mod error;
mod host;
mod inputs;
mod outputs;
mod payload;
@ -37,6 +38,12 @@ pub use payload::{BytesValue, FileRef, Payload};
pub use chain_module_sdk_macros::fai_module;
// Emit-hook plumbing used by the `#[fai_module]` expansion (wasm32)
// so `Context::emit` reaches the WIT `emit-event` host import.
#[doc(hidden)]
pub use host::__set_emit_hook;
pub(crate) use host::emit;
/// Convenience prelude — `use chain_module_sdk::prelude::*;` brings
/// every type a typical module needs into scope.
pub mod prelude {

View file

@ -13,10 +13,13 @@ use std::path::PathBuf;
use sha2::{Digest, Sha256};
/// Identical to FROZEN_WIT_SHA256 in
/// fai/platform :: crates/fai_runtime/tests/wit_freeze.rs.
/// fai/chain :: crates/chain_runtime/tests/wit_freeze.rs.
/// Update both repos in lockstep, never one alone.
/// 2026-07-05 — WIT 1.0.0 → 1.1.0 (additive: host.emit-event). The SDK
/// wit/world.wit is now byte-identical to the platform's, so the two
/// frozen hashes match exactly.
const PLATFORM_FROZEN_WIT_SHA256: &str =
"6f4e2266a4264832a16fe5a3704622cd978b290b90f3300c3ed236d98b689b5f";
"2ba9d6c61c4c77d081679603a9389aef663429ec80f9c386cf9b1ca9d353bf6f";
fn world_wit_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))