defineCommand() is the whole registration surface. It files a function under a string id, remembers the spec that came with it, and returns the function unchanged — no wrapper, no compiler step, no client bundle to erase a body from. Everything else here is the spec: what the URL may carry (args), what the request may carry (input, scope), whether the URL is signed (sealed), and what the handler gets back as Ctx.Registering a handler
defineCommand<Id extends string, A = undefined>(
id: Id,
fn: CommandFn<A, undefined>,
): Command<A, undefined, Id>
defineCommand<Id extends string, S extends Spec>(
id: Id,
spec: S,
fn: CommandFn<Out<S["args"]>, Out<S["input"]>>,
): Command<In<S["args"]>, Out<S["input"]>, Id>Two overloads. Drop the spec and the handler is reachable with nothing declared: args and input are undefined, but the request still arrives with signals, req and patch.
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>,
);
});The id is an explicit string rather than something derived from the file path. A path-derived id means git mv silently breaks every page already open in a browser, and it means the id exists nowhere you can search for. This one you grep: one string finds the definition, every call site, and every line in the access log.
Ids are checked at registration against /^[A-Za-z0-9._-]+$/ — they end up inside a single-quoted JS string in a data-on attribute, so anything else throws. Registering the same id twice throws too. Both happen at import time, so a bad id is a failed boot rather than a surprise at click time.
defineCommand() returns the function it was given, so a command is still an ordinary function you can call or test directly. The converse matters more: an export that was never passed through defineCommand() is not an endpoint. Under a "use server" directive, this export would be a public unauthenticated route.
/**
* Exported, used by the view, reachable from a test — and NOT an endpoint, because
* only defineCommand() registers. Under a "use server" directive this export would be a
* public unauthenticated route.
*/
export const summarize = () =>
`${store().doneCount()}/${store().todos().length} done`;The spec
| field | type | what it is |
|---|---|---|
args | StandardSchemaV1 | Render-time constants baked into the URL by cmd(). Client-visible, and forgeable unless sealed. |
input | StandardSchemaV1 | Validated per-request input, read from the signals this command declared a scope for. |
scope | string | Signal namespace. Selects the subtree the handler reads, and narrows what the client sends. |
sealed | boolean | Set by sealed(). HMACs the args at render time and verifies the signature on the way back in. |
Every field is optional, and each one is doing a job the compiler or the boundary can then do for you. Nothing in the spec is authorization — see args below.
args — render-time constants
const Id = type({ id: "number" });
export const toggle = defineCommand(
"todo.toggle",
{ args: Id },
({ args, patch }) => {
const todo = store().findTodo(args.id);
if (!todo) return log(`no such row ${args.id}`, { level: "refusal" });
store().toggleTodo(args.id);
log(`args decoded ${JSON.stringify(args)} — a number, not "${args.id}"`, {
payload: { args },
});
patch.elements(TodoList(), DelegatedList(), ScopedList());
},
);Args are the answer to which one: the row, the tenant, the price. They are encoded once at render time by cmd() as a single base64url JSON blob in the a query param, so types survive the round trip — { id: 42 } comes back a number, not the string "42".
cmd(toggle, { id: 1 })
// data-on:click="@post('/command/todo.toggle?a=eyJpZCI6MX0')"ctx.args is the decoded blob, and it is validated only when the spec declares an args schema. If the schema is declared and the URL carries no a param at all, dispatch refuses with a 422 before the handler runs — the compiler already stops cmd(toggle), and this stops a hand-built URL.
Args are in the markup, so a client can edit them. That is fine and expected: authorization is a check inside the handler, exactly as it must be for signals. An HMAC on ordinary args protects nothing, because the handler has to make this check anyway.
They are also identifiers, and the module enforces it: cmd() throws at render time once the encoded blob passes 512 characters. The cap is generous — {id: 1} encodes to 11 characters, a UUID to 60 — so anything near it is a whole object, and an object in an argument is almost always state that wants to live server-side under a key.
$sel = 3; @post('/command/todo.remove') — which the log panel's level chips actually do. Right when the value is state: $log.level renders the highlighted chip and survives the request. Wrong for a row id, which nothing displays — you get an ambient signal outliving the click and riding along with every other request in that scope. There is no third option: the JSON body is the signal store, so per-call data lives in the URL or a header, and everything else is ambient by construction.export const remove = defineCommand(
"todo.remove",
{ args: Id },
({ args, patch }) => {
const todo = store().findTodo(args.id);
if (!todo) return log(`no such row ${args.id}`, { level: "refusal" });
// The client can put any id here — see the "forge" button on the page. The
// HMAC would not help; only this check does.
if (todo.owner !== store().me) {
log(`not your row — refused (owner: ${todo.owner})`, {
level: "refusal",
payload: { args, owner: todo.owner, me: store().me },
});
patch.elements(
<Flash tone="bad">
refused: row {args.id} belongs to {todo.owner}
</Flash>,
);
return;
}
store().removeTodo(args.id);
log(`authorized and removed row ${args.id}`, { payload: { args } });
patch.elements(
TodoList(),
DelegatedList(),
ScopedList(),
<Flash tone="ok">removed row {args.id}</Flash>,
);
},
);input — per-request input
Datastar ships every non-underscore signal with every request, so an command that reads the store needs no args at all. input is the schema that turns that free channel into something the handler can rely on.
export const increment = defineCommand(
"demo.increment",
{ input: type({ "count?": "number" }) },
({ input, signals, patch }) => {
const next = (input.count ?? 0) + 1;
// `signals` is everything the client sent; `input` is the validated slice.
log(`whole store rode along — ${Object.keys(signals).length} signals`, {
payload: { input, signalKeys: Object.keys(signals) },
});
patch.signals({ count: next });
},
);
// CASE 3 — validation failure. Both fields are required, and the page never
// defines $step, so this 422s until you set it. Missing input becomes a refusal
// at the boundary rather than NaN arriving in the handler.
export const strictIncrement = defineCommand(
"demo.strictIncrement",
{ input: type({ count: "number", step: "number" }) },
({ input, patch }) => {
log(`passed validation — patching $count → ${input.count + input.step}`, {
payload: { input },
});
patch.signals({ count: input.count + input.step });
},
);ctx.input exists only because a schema declared it: with no input in the spec it is undefined, whatever the client sent. ctx.signals is the raw store as it arrived — unvalidated, and the right thing to read when a command genuinely wants whatever is there. Missing or wrong-typed fields become a refusal at the boundary rather than a NaN three lines into the handler.
scope — one declaration, two jobs
// ── 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) }),
);
},
);scope: "todo" names the signal namespace this command reads — the one behind data-bind:todo.text. Dispatch hands the handler that subtree as ctx.input instead of the whole store, and cmd() reads the same declaration at render time to derive a filter, so the client stops uploading signals this command was never going to look at.
cmd(add)
// @post('/command/todo.add', {filterSignals: {include: /^todo\./}})const Signup = type({
email: "string.email",
plan: "'free' | 'pro'",
seats: "number | string.numeric.parse",
});
export const subscribe = defineCommand(
"news.subscribe",
{ scope: "signup", input: Signup },
({ input, patch }) => {
store().addSub(input.email, `${input.plan} ×${input.seats}`);
log(`seats arrived as a ${typeof input.seats} and validated to a number`, {
payload: { input, seatsType: typeof input.seats },
});
patch.elements(<Subscribers />);
},
);seats is accepted as 3 or as "3" and normalises to a number either way. Signals are typed, so under Datastar it is already a number — but a hand-built POST can send whatever it likes, and the schema is where that stops being the handler's problem.
group() — commands in, one read model out
group<Name extends string>(name: Name, render: () => Renderable | Renderable[]): Group<Name>Bundles the three things a feature declares separately anyway: an id namespace, a signal scope, and the one view that re-renders after a command succeeds.
const cart = group("cart", () => <CartView />);
export const addLine = cart.defineCommand(
"add",
{ input: type({ item: "string > 0" }) },
({ input }) => {
store().addToCart(input.item);
log(`command: add "${input.item}" — the view re-renders itself`);
},
);
export const bump = cart.defineCommand(
"bump",
{ args: type({ id: "number", by: "-1 | 1" }) },
({ args }) => {
store().bumpCart(args.id, args.by);
log(`command: qty ${args.by > 0 ? "+" : ""}${args.by} on line ${args.id}`);
},
);
export const clear = cart.defineCommand("clear", () => {
store().clearCart();
log("command: clear");
});cart.defineCommand("add", …) registers cart.add, scopes it to $cart.* so cmd() emits the filter, and patches the group's view once the handler returns. The handler becomes a command and nothing else — which is the CQRS split, with the read side declared once instead of repeated as a trailing patch.elements(<WholeView />) on every handler. Ids are unchanged from writing them out by hand, so adopting it in an existing feature breaks no HTML already in a browser.
A group name is checked harder than a command id — /^[A-Za-z][A-Za-z0-9_]*$/ — because it is interpolated into a RegExp literal that ends up inside an attribute, as well as being read as a signal path. The same check now applies to a plain scope.
{ autoPatch: false }, or a whole-view patch lands on top of the last step it sent.Validation
Validation is Standard Schema — the module imports no validator at all, it just types args and input against the ~standard interface that ArkType, Zod 4, Valibot and friends all expose. Two commands in the same app can use two different libraries.
export type StandardSchemaV1<Input = unknown, Output = Input> = {
readonly "~standard": {
readonly version: 1;
readonly vendor: string;
readonly validate: (
value: unknown,
) => StandardResult<Output> | Promise<StandardResult<Output>>;
readonly types?:
{ readonly input: Input; readonly output: Output } | undefined;
};
};// ── ArkType ──────────────────────────────────────────────────────────────────
const ArkCoupon = type({
code: "string >= 4",
percent: "number | string.numeric.parse",
});
export const arkCheck = defineCommand(
"coupon.ark",
{ scope: "coupon", input: ArkCoupon },
({ input, patch }) => {
log(`arktype accepted ${JSON.stringify(input)}`, {
payload: { vendor: "arktype", input, percentType: typeof input.percent },
});
patch.elements(
<Verdict vendor="arktype" ok={true} detail={JSON.stringify(input)} />,
);
},
);
// ── Valibot — same slot, same Ctx, no change to commands.ts ───────────────────
const ValCoupon = v.object({
code: v.pipe(v.string(), v.minLength(4)),
percent: v.union([v.number(), v.pipe(v.string(), v.transform(Number))]),
});
export const valibotCheck = defineCommand(
"coupon.valibot",
{ scope: "coupon", input: ValCoupon },
({ input, patch }) => {
log(`valibot accepted ${JSON.stringify(input)}`, {
payload: { vendor: "valibot", input, percentType: typeof input.percent },
});
patch.elements(
<Verdict vendor="valibot" ok={true} detail={JSON.stringify(input)} />,
);
},
);A failed validation throws Invalid, which dispatch turns into a 422 carrying the issue messages. It happens before the stream is opened, so a refused request cannot patch anything.
ctx.args and ctx.input. If a handler spreads either into a record, that is mass assignment — declare the schema strict.sealed() — args as capability
sealed<S extends Omit<Spec, "sealed">>(spec: S): S & { sealed: true }Wraps a spec to mark its args as a capability: HMAC-signed at render time, verified by dispatch on the way in with a constant-time comparison. A tampered payload never reaches the handler — it is a 403 at the boundary.
The signature has one canonical message and two carriers. cmd() puts it in a Command-Signature header, which keeps it out of request URLs and therefore out of access logs, proxies and error reports — and makes a cross-origin fetch trigger a preflight this server never answers, so the request dies before it is sent. The old ?a=<body>.<mac> query form is still accepted so HTML from the previous deploy keeps working, but nothing emits it any more. If two carriers disagree the request is refused rather than letting the most permissive one win. sig() hands you one on its own, for a caller carrying it out of band.
export 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 />);
},
);Use it for a value the handler must trust without re-deriving: a resolved tenant, a price, a role decided at render time by code that knew who you were. Do not reach for it for ordinary args — a signature is not authorization, and the handler must authorize args exactly as it must authorize signals.
The command id is signed alongside the body, length-prefixed, so a token is only ever valid for the command it was minted for. Otherwise, two sealed commands with a compatible arg shape — refund.issue({amount}) and payout.send({amount}) — would share tokens. Calling a sealed command with no args is refused too: url() throws at render time, and dispatch answers 403.
sealed() for anything carrying real capability, sign a canonical tuple of those and verify each part. And set COMMAND_KEY: unset, each process invents a random one and sealed URLs break across restarts and instances.Ctx — what the handler gets
export type Ctx<A = undefined, I = undefined> = {
/** Render-time arguments baked into the URL by cmd(). Client-visible; forgeable unless sealed. */
args: A;
/** Validated per-request input, read from the signals this command declared a scope for. */
input: I;
/** Raw. Every non-underscore Datastar signal rides along with every request for free. */
signals: Record<string, any>;
req: Request;
/** Patches back to the client, over the stream this request is answered on. */
patch: Patch;
};| field | raw / validated | notes |
|---|---|---|
args | decoded; validated if declared | From the URL. Undefined when no ?a= was sent and none was required. |
input | validated | Undefined unless the spec declares an input schema. The scoped signal subtree, or the whole store when no scope is declared. |
signals | raw | The whole signal store as it arrived, unvalidated. |
req | raw | The Request, for headers and cookies. |
patch | — | Patches back to the client, over the stream this request is answered on. |
patch — what goes back
// Not `Promise<Renderable>`: a self-referential thenable is TS error 1062.
export type Renderable = RenderNode | Promise<RenderNode>;
/**
* What handlers actually get. The SSE layer speaks strings — `patchSignals`
* takes serialized JSON and `patchElements` takes markup — which pushes a
* `JSON.stringify` and a `.html` onto every call site. That ceremony is noise:
* the handler already has the object and the node. So `ctx.patch` is this, and
* the raw generator stays available for the options the sugar does not cover.
*/
export type Patch = {
/** One patch per node. `patch.elements(...lists(), <Flash />)`. */
elements(...nodes: Renderable[]): Promise<void>;
/** `patch.signals({ count: 3 })` — serialized for you. */
signals(
values: Record<string, unknown> | string,
options?: Record<string, unknown>,
): Promise<void>;
/** Remove by CSS selector. */
remove(selector: string): Promise<void>;
/** Run a script on the client. */
script(js: string, options?: Record<string, unknown>): Promise<void>;
/** The underlying generator, for selector/mode/namespace and friends. */
raw: SsePatch;
};The SSE layer speaks strings: patchSignals wants serialized JSON and patchElements wants markup. That pushes a JSON.stringify and a .html onto every call site, which is noise — the handler already has the object and the node. So ctx.patch is the sugar and the generator stays reachable.
| call | does |
|---|---|
patch.elements(...nodes) | One patch per node. Datastar matches each by id. |
patch.signals(obj, opts?) | Serializes for you; a pre-serialized string is accepted too. |
patch.remove(selector) | Remove by CSS selector. |
patch.script(js, opts?) | Run a script on the client. |
patch.raw | The underlying generator — selector, mode, namespace, removeSignals, and anything else the sugar does not wrap. |
elements takes a string, an object carrying .html, anything with its own toString, or a promise of any of those — the entire coupling between this module and a template layer. The JSX here returns .html; Hono JSX and Hono's html tag stringify themselves, sometimes asynchronously; an async component is a promise of a node. All go straight in.
The toString arm is structurally wide — every object has one, so the type cannot tell a template node from a Todo. The runtime can: a value still carrying Object.prototype.toString throws rather than patching [object Object] into the page.
signals() would overtake an async elements() written above it. dispatch drains before the handler's callback resolves, because the stream closes with it. If you inject your own SSE layer, stream() must hold the stream open until the onStart promise resolves, or every asynchronous patch is truncated.One response can carry many patches. The stream's Response is returned as soon as its ReadableStream is constructed and the handler runs inside it, so each call lands on the client as it happens rather than being batched at the end:
export const runImport = defineCommand("demo.stream", async ({ patch }) => {
for (let i = 0; i < STEPS.length; i++) {
// Each send() writes into the already-open response.
patch.elements(<Progress at={i + 1} />);
await Bun.sleep(420);
}
log(`streamed ${STEPS.length} patches down one open response`, {
payload: { steps: STEPS, patches: STEPS.length },
});
});dispatch answers 406 to a caller that cannot read one — a plain browser form post included. There is no urlencoded body parsing, no redirect and no patch sink: a handler that runs always has somewhere to write.Logging from a handler
There is no ctx.log. The module has no logging API at all — no recorder, no level vocabulary, no patch-recording proxy. A handler logs the way any other function does: import your logger and call it.
The only thing worth arranging is correlation, so a line knows which request it belongs to. AsyncLocalStorage does that without threading a parameter through every signature. This demo's version is nine lines:
/**
* Annotate the request currently in flight. Handlers import this like any other
* helper — the request it belongs to comes from async context rather than a
* parameter threaded through every signature.
*/
export const log = (
msg: string,
extra?: { level?: Level; payload?: unknown },
) => void als.getStore()?.lines.push({ msg, ...extra });Everything else a request trace wants is already reachable: what went back over the wire by wrapping the patch in the SseAdapter you inject, and why a request was refused from the status and body of the Response dispatch returns. See Runtime → observability for the whole picture.