datastar · commands

docsGuide

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 SSE

Install

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.ts

Bun 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 routes
One registry, always. setupCommands 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:

pingbasics.commands.tsx
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().

Registration is per-function, not per-file. Only 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

ChannelForDeclared asReference
signalsuser input, form stateinput: schema (+ optional scope)defineCommand()
?a= argsrender-time constants: which row, which pageargs: schemacmd()
sealed ?a=render-time constants the handler must trustsealed({ args })defineCommand()
el.datasetvalues only known at click timedcmd(...) — one listener per listdcmd()

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:

addtodos.commands.tsx
// ── 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:

loadCommandscommands.ts
// ── 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);
Unknown command id returns 410, which is the "your HTML predates this deploy" signal. Pair it with a reload in a Datastar fetch-error handler and stale tabs self-heal.

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.ts
grantRoleadmin.commands.tsx
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 />);
  },
);
Signing is opt-in, and rarely what you want. An HMAC on ordinary args protects nothing — the handler has to authorize them anyway, exactly as it must authorize signals. 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

PageWhat is there
/docs/commandDeclaring a command: ids, args and input schemas, scope, sealed, the ctx a handler receives.
/docs/cmdCalling commands from markup: cmd(), cmd.get, url() for forms and links, dcmd() delegation.
/docs/runtimedispatch(), the refusals it makes, loadCommands(), and why the trace lives in your app rather than the module.
The tourEvery input channel as a live worked example, each card showing its own real source.
PlaygroundThe real module bundled for the browser, with the emitted types loaded into the editor.