Rust
The sigmx crate is the server side of sigmx for Rust: it reads the signals a request carries and builds the responses the client knows how to apply. It has no required dependencies, works on native servers and compiles to wasm32-unknown-unknown for Cloudflare Workers. It also carries the client of the same release, so a Rust project never needs npm to get it.
Install
Section titled “Install”[dependencies]sigmx = { version = "0.2", features = ["axum"] }| feature | |
|---|---|
json (default) |
reading signals into serde_json or your own Deserialize type |
client (default) |
the embedded client: sigmx::client::{STANDALONE, FILES, write_standalone, write_esm} |
axum |
extractors and responses for axum 0.8; no tokio dependency, so it builds for wasm32 |
worker |
the same for worker::Request and worker::Response on Cloudflare Workers |
http |
http::Response<String> builders for any other http-based framework |
scan |
auto mode: scan your .rs and template files, generate the entry module |
cli |
the sigmx binary (cargo install sigmx --features cli) |
Reading signals
Section titled “Reading signals”use serde::Deserialize;use sigmx::axum::ReadSignals;
#[derive(Deserialize)]struct Search { q: String, #[serde(default)] page: u32 }
async fn search(ReadSignals(s): ReadSignals<Search>) -> Sse { … }GET and DELETE requests carry signals in the sigmx query parameter, other methods in a JSON body, and contentType: 'form' requests as form fields. A payload that cannot be parsed answers 400, one that does not match the type 422, both with a JSON body. Option<ReadSignals<T>> is None for a browser navigation, and IsSigmxRequest(bool) tells you the same thing without reading the body, so one route can serve a page or a partial.
Without a framework: sigmx::read_signals(&RawRequest { method, content_type, query, body }).
Responding
Section titled “Responding”use sigmx::prelude::*;use sigmx::axum::Sse;
html(page) // text/html; the client morphs it by idhtml(row).selector("#rows").mode(PatchMode::Append) // the sigmx-selector and sigmx-mode headersjson(json!({ "count": 3 })).only_if_missing(true) // merged into the signalsSse::events([ // several patches, sent at once PatchElements::new(list).into_event(), PatchSignals::new(r#"{"stale":false}"#).into_event(),])PatchElements::new(markup) // a lone event is a response tooMarkup is anything that converts into a String, so maud::Markup and rendered askama templates go straight in. The event builders are PatchElements::new(html) with .selector() and .mode(), PatchElements::remove(selector), PatchSignals::new(json) / ::from_value(json!(…)) / ::remove(paths) with .only_if_missing(), and ExecuteScript::new(js); each takes .id() and .retry() for the SSE fields, and Event::custom("toast").line("message", "Saved") builds events for your own handlers. Event’s Display is the wire format, so any framework can send it.
Streaming
Section titled “Streaming”Sse streams without a runtime of its own. Sse::run polls the handler’s future while the body is being read, so there is nothing to spawn and it works the same under tokio and inside a Worker:
async fn progress() -> Sse { Sse::run(|s| async move { for step in 1..=10 { tokio::time::sleep(Duration::from_millis(150)).await; if !s.patch_signals(format!(r#"{{"progress":{}}}"#, step * 10)) { return; // the client disconnected } } s.patch_elements("<p id=\"note\">Done.</p>"); })}Sse::channel() returns a cloneable SseWriter and the response, for a spawned task or a broadcast subscription; the stream ends when every writer is dropped. Sse::stream(s) takes any Stream<Item = Event>.
Cloudflare Workers
Section titled “Cloudflare Workers”A Worker that routes with axum (the worker crate’s axum feature) uses sigmx::axum unchanged. Worker futures are not Send; wrap a streaming handler’s future in worker::send::SendFuture:
async fn progress() -> Sse { Sse::run(|s| worker::send::SendFuture::new(async move { worker::Delay::from(Duration::from_millis(150)).await; s.patch_signals(r#"{"progress":10}"#); }))}Handlers written against worker::Request and worker::Response use sigmx::worker instead: read_signals(&mut req).await, html(…), events([…]), and run(|s| async move { … }) with a plain future. The repository’s Worker example is complete, including the client build below.
The client without Node
Section titled “The client without Node”The crate embeds the client of its own version. Get it out with the binary:
cargo install sigmx --features clisigmx client standalone public/sigmx.js # every plugin; <script type="module" src="/sigmx.js">sigmx client unpack public/vendor/sigmx # the ES modules, for a bundle of your ownsigmx entry src --from ./vendor/sigmx --out public/sigmx.js # auto mode without a bundleror from a Rust build step of your own (the client and scan features):
sigmx::client::write_esm("public/vendor/sigmx")?;let mut o = sigmx::scan::Options::default();o.include = vec!["src".into(), "assets/js".into()]; // maud templates, your own moduleso.from = sigmx::scan::Source::Dir("./vendor/sigmx".into());o.out = Some("public/sigmx.js".into());o.custom = vec![("shout".into(), "public/js/shout.js".into())];let selection = sigmx::scan::scan(&o)?;std::fs::write("public/sigmx.js", sigmx::scan::Entry::selection(selection, o.from).module())?;The scanner applies the auto mode rules to .rs, .html and template files (data-<name> for directives, @name( for functions, custom plugins read from their attribute({ name }) definition) and writes an entry that imports each selected plugin from its own module in the unpacked tree (./vendor/sigmx/plugins/functions/request.js rather than the plugins/index.js barrel). A browser loads that entry as it is and fetches only the selected modules; a bundler you already run (swc, esbuild, Vite) turns it into one file, and one that does not tree-shake re-exports, such as swc_bundler, still ships only the selection. Entries generated for Source::Package import from sigmx/plugins, which the npm package marks side-effect free so Vite and esbuild prune it. Projects with an npm toolchain can keep installing sigmx from npm and using sigmx/vite instead; the crate does not mind where the client came from.
Your own plugins
Section titled “Your own plugins”A plugin is a module exporting attribute({ name, mount }), action({ name, call }) or handler({ name, handle }), with the helpers imported from the unpacked tree (./vendor/sigmx/kernel/index.js) or from sigmx on npm; see writing a plugin. The crate ships it three ways:
use sigmx::scan::{CustomPlugin, Entry, Kind};
// Scanned like the built-ins: read from the file (never run), shipped when `data-shout` is used.// Declare the metadata when the file is not in the usual shape; `.always()` ships it regardless.o.custom = vec![ CustomPlugin::new("shout", "public/js/shout.js"), CustomPlugin::new("toast", "public/js/toast.js").named("toast", Kind::Handler).always(),];
// Added to any entry, presets included, by a specifier relative to the output file.let entry = Entry::preset("essentials", from).plugin("shout", "./js/shout.js");
// Registered at runtime from code appended to the entry. Plugins are plain objects, so the same// call works against the script-tag build: `window.sigmx.use({ type: 'attribute', … })`.let entry = entry.append("sigmx.use({ type: 'attribute', name: 'inline', mount({ el }) { … } });");The CLI has the same three: --custom shout=assets/js/shout.js, --custom toast=assets/js/toast.js:handler:toast and --plugin shout=./js/shout.js. A custom handler counts as used when its name appears quoted anywhere in the scanned files, so Event::custom("toast") in a Rust handler is enough to keep the toast handler in the bundle.
serve_client serves the standalone build from memory with an ETag (Router::new().route("/sigmx.js", get(serve_client))); on Workers, writing it into the [assets] directory at build time is better, since assets are served before the Worker runs.
Coming from the Datastar Rust SDK
Section titled “Coming from the Datastar Rust SDK”datastar::prelude::* becomes sigmx::prelude::*; DatastarEvent is Event; PatchElements::new(html).into(), PatchSignals::new(json).into(), ExecuteScript::new(js) and to_string() are unchanged, and ElementPatchMode is an alias of PatchMode. The client now sends a sigmx-request header and a sigmx query parameter, which ReadSignals and is_sigmx_request read; a backend keeping its own header check updates those two strings. use_view_transition is dropped. Until every backend emits sigmx-patch-*, createSigmx({ eventPrefix: ['sigmx-', 'datastar-'] }) accepts both.