cmd(fn, args) renders a server function into an endpoint expression at render time; dispatch() receives the POST, decodes and validates it, runs the handler and streams SSE back. One file, no dependencies, no build step, no "use server" directive.Why no compiler is needed
In React, "use server" needs a build step for one reason: the function body must be erased from the client bundle and replaced with a fetch stub. Datastar has no client bundle — the client is data-* attributes. So cmd() runs on the server at render time and only has to emit a string. That is a runtime concern.
The whole mechanism is three things:
// 1. a registry: id -> fn, and fn -> id (so templates can reference either)
// 2. a renderer: cmd(fn, args) -> "@post('/command/<id>?a=<args>')"
// 3. a dispatcher: POST /command/:id -> look up fn, decode+validate, run, stream SSEInstall
There is no package. commands.ts is the whole mechanism in one file with zero dependencies — three Node builtins and nothing else. Vendor it:
curl -fsSL https://gist.githubusercontent.com/derekr/90babab40b39f0439b42f1690e0b421f/raw/commands.ts -o commands.tsBun is assumed only for Bun.Glob in loadCommands() — drop that one function and the rest is plain Node. Validation is Standard Schema, so bring ArkType, Zod 4 or Valibot; the module imports no validator at all.
Setup
The SSE layer is injected, and the suggested thing to inject is the Datastar SDK you already have:
import { ServerSentEventGenerator } from "@starfederation/datastar-sdk/web";
import { setupCommands } from "./commands";
export const { cmd, dcmd, defineCommand, dispatch, loadCommands } = setupCommands({
sse: ServerSentEventGenerator,
});SseAdapter is just { stream, readSignals }, which the SDK's generator satisfies structurally — so a different runtime's SDK, your own writer, or a fake in tests all drop in the same way.
dispatch() claims everything under /command/ and returns null for anything else, so it sits in front of your own routing:
const res = await dispatch(req);
if (res) return res;
// ...your routessetupCommands returns the API, but the module-level exports keep working and refer to the same registry — which matters, because command modules discovered by loadCommands() self-register at import time and can never be handed a factory result.Your first command
A command is a plain function registered under an explicit string id. ctx carries the decoded input and a patch that takes the values you already have, not their serializations:
export const ping = defineCommand("demo.ping", ({ patch }) => {
log("no args, no input");
patch.elements(
<code id="ping-out" class="mono" style="color:var(--aqua)">
pong @ {new Date().toISOString().slice(11, 23)}
</code>,
);
});Call it from markup. cmd() runs at render time and returns the expression Datastar will evaluate on click:
<button data-on:click={cmd(ping)}>ping</button>
// renders: <button data-on:click="@post('/command/demo.ping')">ping</button>With args, the values are JSON-encoded into one base64url query param, so types survive the round trip — { id: 1 } comes back a number, not a string:
<button data-on:click={cmd(remove, { id: todo.id })}>delete</button>
// renders: @post('/command/todo.remove?a=eyJpZCI6MX0')The id is explicit rather than derived from the file path, so moving or renaming the module does not break HTML already open in a browser — and so one grep finds the definition, every call site, and every line in the access log. A string reference, cmd("demo.ping"), is type-checked exactly as tightly as the function reference; see cmd() · dcmd().
defineCommand() creates an endpoint; export exists so a template can reference the function. Exporting a helper for a test does not ship a route — which whole-file "use server" registration would.The four input channels
| Channel | For | Declared as | Reference |
|---|---|---|---|
| signals | user input, form state | input: schema (+ optional scope) | defineCommand() |
| ?a= args | render-time constants: which row, which page | args: schema | cmd() |
| sealed ?a= | render-time constants the handler must trust | sealed({ args }) | defineCommand() |
| el.dataset | values only known at click time | dcmd(...) — one listener per list | dcmd() |
Signals are the free channel: Datastar ships every non-underscore signal with every request, so a counter needs no args at all. scope narrows both directions at once — dispatch hands the handler that signal subtree as ctx.input, and cmd() derives a filterSignals from the same declaration so the client stops shipping the whole signal store:
// ── scoped input ─────────────────────────────────────────────────────────────
// `scope: "todo"` means: read the $todo.* signal subtree, and have cmd() emit a
// filterSignals so the click stops shipping the entire signal store.
export const add = defineCommand(
"todo.add",
{ scope: "todo", input: type({ text: "string > 0" }) },
({ input, signals, patch }) => {
store().addTodo(input.text);
log(
`scoped input ${JSON.stringify(input)} — client sent only ${JSON.stringify(Object.keys(signals))}`,
{
payload: { input, signalKeys: Object.keys(signals) },
},
);
patch.signals({ todo: { text: "" } });
patch.elements(
TodoList(),
DelegatedList(),
ScopedList(),
// The row lands in card 03, which is scrolled off the top by the time you
// are reading card 05 — so the card also shows what it just proved.
ScopedEcho({ input, keys: Object.keys(signals) }),
);
},
);Discovery: import every command at boot
A POST can land on a process that never rendered the page, so every command module must be imported at boot on every instance. That is the entire job the "use server" directive was doing; loadCommands() does it from a filename, without reading each source file:
// ── discovery ────────────────────────────────────────────────────────────────
// A POST can land on a process that never rendered the page, so every command
// module must be imported at boot on every instance. That is the entire job
// "use server" was doing; a filename does it without reading each source file.
export async function loadCommands(
dir: string,
pattern = "**/*.commands.{ts,tsx}",
) {
const found: string[] = [];
for await (const rel of new Bun.Glob(pattern).scan({ cwd: dir })) {
await import(pathToFileURL(resolve(dir, rel)).href);
found.push(rel);
}
return found.sort();
}One line at boot, before you serve anything:
const files = await loadCommands(import.meta.dir);COMMAND_KEY
The only configuration. sealed() signs args with it on the way out and verifies them on the way in. Unset, each process generates a random key at boot and says so loudly — safe, but sealed URLs then break across restarts and across instances. Set it to a shared secret before deploying anything that uses sealed():
COMMAND_KEY=$(openssl rand -hex 32) bun server.tsexport const grantRole = defineCommand(
"admin.grantRole",
sealed({ args: type({ role: "'viewer' | 'editor' | 'admin'" }) }),
({ args, patch }) => {
store().setRole(args.role);
log(`sealed args verified — role is now ${args.role}`, {
payload: { args, verified: true },
});
patch.elements(<RoleBadge />);
},
);sealed() is for args that carry capability: a resolved tenant, a price, a role. The signature attests provenance and the schema attests shape, so sealed args are validated too.Where to go next
| Page | What is there |
|---|---|
| /docs/command | Declaring a command: ids, args and input schemas, scope, sealed, the ctx a handler receives. |
| /docs/cmd | Calling commands from markup: cmd(), cmd.get, url() for forms and links, dcmd() delegation. |
| /docs/runtime | dispatch(), the refusals it makes, loadCommands(), and why the trace lives in your app rather than the module. |
| The tour | Every input channel as a live worked example, each card showing its own real source. |
| Playground | The real module bundled for the browser, with the emitted types loaded into the editor. |