datastar · commands

docscmd() · dcmd()

defineCommand() makes a function reachable. cmd() is the other half: it turns that registration into the string you put in a data-on attribute. It runs at render time, on the server, so what reaches the browser is a finished Datastar expression with the arguments already encoded — there is nothing left for the client to assemble. dcmd() is the same thing for a list, where one listener replaces one expression per row.

What cmd() produces

cmd(ref, args?, opts?) returns a string. That is the whole of it — a Datastar action expression naming a URL under /command/, with the arguments encoded into one query parameter.

cmd<T extends Ref>(ref: T, ...rest: CmdParams<T>): string

Emits @post('/command/<id>?a=<args>'). This is the one you want for anything that mutates.

cmd.get<T extends Ref>(ref: T, ...rest: CmdParams<T>): string

The same expression with @get instead of @post — which will not reach a handler as shipped: dispatch() answers 405 to every method but POST (see below). It is here for a dispatcher you have modified to allow read-only commands, and nothing else.

cmd.url<T extends Ref>(ref: T, ...rest: ArgsParam<T>): string

The bare URL, no expression wrapper and no options — for a caller that builds its own request: another service, a test, a template in another language. Not for <form action> or <a href>: dispatch answers 405 to anything but POST and 406 to anything that cannot read an SSE response, so a browser navigation never reaches a handler. Exported at the top level as url() too.

It throws for a sealed() command rather than handing back an unsigned URL that would 403 on arrival — the signature does not ride in the URL. Pair it with cmd.sig.

cmd.sig<T extends Ref>(ref: T, ...rest: ArgsParam<T>): string

The signature on its own, for a caller carrying it out of band. cmd() puts it straight into the header it emits, so this is the only way to get at one. Empty string unless the command is sealed. Exported at the top level as sig() too.

A real call site. The row renders on the server; each button carries its own finished expression:

Rowtodos.commands.tsx
// ── A. one command expression per row ─────────────────────────────────────────
const Row = (t: Todo) => (
  <div class={`row ${t.done ? "done" : ""}`} data-todo-id={t.id}>
    <button class="tick" data-on:click={cmd(toggle, { id: t.id })}>
      {t.done ? "✓" : ""}
    </button>
    <span class="txt">{t.text}</span>
    <span class="owner">{t.owner}</span>
    {t.owner === store().me ? (
      <button class="ico rm" data-on:click={cmd(remove, { id: t.id })}>
        ✕
      </button>
    ) : (
      <span></span>
    )}
  </div>
);

What that renders, for { id: 1 }:

data-on:click="@post('/command/todo.remove?a=eyJpZCI6MX0')"

A <form> with no action and no method: signals-only means there is no browser submit to fall back to, so data-on:submit__prevent is the entire submit path. The element stays a form for what the platform gives you free — Enter submits, native validation runs, labels and focus order work.

SignupFormsignup.commands.tsx
export const SignupForm = () => {
  return (
// No `action` and no `method`: nothing here ever performs a browser submit.
    // The element stays a <form> for what the platform gives you for free —
    // Enter submits, required/type validation runs, labels and focus order work.
    <form class="stack" data-on:submit__prevent={cmd(subscribe)}>
      {/* JSX attribute names cannot contain `.`, so dotted signal paths (and dotted
          event modifiers like __debounce.500ms) go through a spread. */}
      <input
        name="email"
        placeholder="you@example.com"
        {...{ "data-bind:signup.email": true }}
      />
      <select name="plan" {...{ "data-bind:signup.plan": true }}>
        <option value="free">free</option>
        <option value="pro">pro</option>
      </select>
      <input
        name="seats"
        type="number"
        value="1"
        min="1"
        style="width:5.5rem"
        {...{ "data-bind:signup.seats": true }}
      />
      <button class="btn" type="submit">
        subscribe
      </button>
    </form>
);
};
Commands are POST only. dispatch() answers 405 (with allow: POST) to every other method, because prefetchers, crawlers, <img> and link scanners all issue GETs — and cmd.get would otherwise put sealed capability tokens into URLs that leak through history and Referer.

The argument codec

Arguments go into the URL as one JSON blob in one query parameter, ?a=. One blob rather than one parameter per key is what makes types survive the round trip: { id: 42 } comes back as the number 42, not the string "42", with no coercion step and no schema needed just to undo the transport.

enc, deccommands.ts
// ── arg codec ────────────────────────────────────────────────────────────────
// One JSON blob in one query param, so types survive the round trip ({id: 42}
// comes back a number). base64url rather than readable JSON specifically so the
// signed path never trips over URL-encoding normalisation.

const enc = (a: unknown) =>
  Buffer.from(JSON.stringify(a)).toString("base64url");

const dec = (b: string) => JSON.parse(Buffer.from(b, "base64url").toString());

base64url rather than readable JSON, specifically so the signed path never trips over URL-encoding normalisation — a proxy or a browser that re-encodes a percent-escape would change the bytes the HMAC was computed over. base64url has no characters that anything downstream wants to rewrite.

mint() is where that happens: it resolves the reference, encodes the args, and signs them when the command is sealed. The signature comes back beside the URL rather than inside it, and each caller decides how to carry it — cmd() in a header, dcmd() in the row's dataset, sig() wherever you like.

mintcommands.ts
/** Resolve a reference, encode its args, and sign them if the command is sealed. */
function mint(ref: Ref, args?: unknown): Minted {
  const id = typeof ref === "string" ? ref : idOf.get(ref);
  if (!id)
    throw new Error(
      `cmd(): not a registered command (did you forget to wrap it in defineCommand()?)`,
    );
  const fn = byId.get(id);
  if (!fn) throw new Error(`cmd(): no command registered as "${id}"`);
  if (args === undefined) {
    if (isSealed(fn))
      throw new Error(`cmd(): "${id}" is sealed but was given no args`);
    return { id, href: `/command/${id}`, sig: "" };
  }
  const b = enc(args);
  if (b.length > maxArgs)
    throw new Error(
      `cmd(): "${id}" args encode to ${b.length} characters, over the ${maxArgs} cap. ` +
        `Args go in the URL, so they are identifiers rather than payloads — keep the ` +
        `data server-side and pass its key. Raise maxArgs at setup if you mean it.`,
    );
  return {
    id,
    href: `/command/${id}?a=${b}`,
    sig: isSealed(fn) ? mac(id, b) : "",
  };
}
The encoding is not encryption and not a signature. Ordinary args are plainly visible and freely forgeable by whoever holds the page — the handler must authorize them, exactly as it must authorize signals. sealed() is for the args a handler needs to trust without re-deriving them.

Two ways to name a command

A reference is either the function itself or its id string. Both resolve to the same args type, so both are checked the same.

Refcommands.ts
/**
 * A reference is either the function itself or its id. Both resolve to the same
 * args type, so `cmd(remove, {id})` and `cmd("todo.remove", {id})` are equally checked
 * — and an unregistered id is a compile error, not a 410 at runtime.
 */
export type Ref = Command<any, any, any> | keyof CommandMap;
cmd(remove, { id: 3 });          // by function
cmd("todo.remove", { id: 3 });   // by id — same check, same completions

The string form works because of one declaration-merging trick. CommandMap ships empty from the module, and the app fills it in — which keeps commands.ts from ever importing a feature module, and so from ever being in an import cycle with one.

CommandsIncommands.ts
/**
 * Builds an id -> args map from a module's exports. Use with `import type`.
 *
 * The `string extends Id` guard drops everything that is not really a command:
 * the phantom brands are optional, so a plain view export like `summarize()`
 * structurally satisfies Command with Id inferred as the wide `string`, which
 * would otherwise collapse the whole map into an index signature.
 */
export type CommandsIn<M> = {
  [
    K in keyof M as M[K] extends Command<any, any, infer Id>
      ? string extends Id
        ? never
        : Id
      : never
  ]: M[K] extends Command<infer A, any, any> ? A : never;
};

registry.ts is the whole of the app side. Every import is an import type, so the file contributes nothing at runtime — no barrel, no cycle, no second registration path. Discovery stays the boot-time glob in loadCommands(); this only teaches the compiler which ids exist.

import type { CommandsIn } from "./commands";
import type * as todos from "./todos.commands";
import type * as admin from "./admin.commands";

type AllCommands = typeof todos & typeof admin;

declare module "./commands" {
  interface CommandMap extends CommandsIn<AllCommands> {}
}
Without a registry.ts, CommandMap stays empty, keyof CommandMap is never, and the string form does not typecheck at all — there is no id to complete and nothing to check against. And because CommandsIn reads a module's exports, only exported commands ever appear in the map. A command that self-registers but is never exported is reachable over HTTP and invisible to the string form.

Which one to reach for

Prefer the function reference, and not for type reasons — the types are the same either way. It creates a real import edge. Deleting an command breaks every call site immediately, because the import fails. Go-to-definition works. Renames follow. The string form has all the same compile-time safety but only breaks once registry.ts is re-checked: the same signal, later.

Reach for the string when you cannot hold a reference — HTML coming out of another template engine or another language, blocks stored in a CMS, config-driven UIs where the command name is data, or to break an import cycle between a shared layout and a feature module.

Args are required when the command declares them

If a command declares an args schema, cmd() will not compile without args. Otherwise cmd(remove) mints a URL that hands the handler undefined — a 500 at click time for a mistake the compiler can already see. The enforcement is a conditional rest tuple:

ArgsParam, CmdParamscommands.ts
/**
 * Args are REQUIRED when the command declares an args schema, optional when it
 * doesn't. Without this, `cmd(remove)` compiles and mints a URL that hands the
 * handler `undefined` — a 500 at click time for a mistake the compiler can see.
 */
type ArgsParam<T> =
  undefined extends ArgsOf<T> ? [args?: ArgsOf<T>] : [args: ArgsOf<T>];

type CmdParams<T> =
  undefined extends ArgsOf<T>
    ? [args?: ArgsOf<T>, opts?: DsOptions]
    : [args: ArgsOf<T>, opts?: DsOptions];

types.test.ts pins each of these with @ts-expect-error, so the build fails if one of them stops being an error:

CallWhy it does not compile
cmd(remove, { id: "3" })the args schema says id: number
cmd(remove, { nope: 3 })unknown key — the arg object is checked, not just its presence
cmd(remove)declares args, so args are required — this is the one that would have minted a broken URL
cmd(grantRole)same rule, sealed or not
cmd("todo.remvoe", { id: 3 })a typo in the id is a compile error, not a 410 at click time
cmd("todo.removedInV2")an id that was never registered
cmd("admin.grantRole", { role: "owner" })"owner" is not in the declared role union — string refs are checked to the same depth as function refs
The compiler stops cmd(remove); dispatch() stops a hand-built URL with the same gap, raising args: required but absent as a 422. Both, because only one of them sees requests that were not rendered by your templates.

filterSignals, derived from scope

cmd() cannot read signals. It runs at render time, possibly in a different process than the one that will serve the click, so there is no signal store for it to consult. But it can read the command's declared scope and narrow what the client agrees to send:

derivedOptscommands.ts
/**
 * cmd() cannot parse signals — it runs at render time, possibly on a different
 * process than the one that will serve the click. But it can read the command's
 * declared scope and narrow what the client sends, so the same declaration that
 * validates on the way in also trims the payload on the way out.
 */
function derivedOpts(ref: Ref): DsOptions | undefined {
  const fn = typeof ref === "string" ? byId.get(ref) : ref;
  const scope = fn && specOf.get(fn)?.scope;
  return scope
    ? { filterSignals: { include: new RegExp(`^${scope}\\.`) } }
    : undefined;
}

So one scope declaration does two jobs. dispatch() hands the handler that signal subtree as ctx.input, and cmd() emits a filterSignals that keeps the rest of the store off the wire:

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) }),
    );
  },
);
cmd(add)
// → @post('/command/todo.add', {filterSignals: {include: /^todo\./}})

Measured in the browser on the tour: the add-todo POST body is 42 bytes and omits $count and $noise entirely. Without the filter, every non-underscore signal on the page rides along with every click.

dcmd() — one listener for a list

A hundred rows with two buttons each means two hundred copies of an expression that differs only in an id. dcmd() moves the part that repeats — the command id — up to a single listener on the container, and leaves the part that genuinely differs on the row.

dcmd(...cmds: Command[]): { on: string; for(fn, ...args): { 'data-cmd-i': string; 'data-cmd-q': string } }

rows.on goes on the container. rows.for(fn, args) spreads onto the control.

rows, DRowtodos.commands.tsx
// ── B. one listener for the whole list ───────────────────────────────────────
// The same two operations, wired the other way. Only the id moves to the
// listener, as a rendered allowlist; the args are still encoded by cmd()'s codec
// at render time, so there is no client-side encoding and sealed commands work.
const rows = dcmd(toggle, remove);

const DRow = (t: Todo) => (
  <div class={`row ${t.done ? "done" : ""}`} data-todo-id={t.id}>
    <button class="tick" {...rows.for(toggle, { id: t.id })}>
      {t.done ? "✓" : ""}
    </button>
    <span class="txt">{t.text}</span>
    <span class="owner">{t.owner}</span>
    {t.owner === store().me ? (
      <button class="ico rm" {...rows.for(remove, { id: t.id })}>
        ✕
      </button>
    ) : (
      <span></span>
    )}
  </div>
);

What the two halves render to:

// the container, once
data-on:click="let _t = evt.target.closest('[data-cmd-i]'); _t && @post(`/command/${['todo.toggle','todo.remove'][_t.dataset.cmdI]}${_t.dataset.cmdQ || ''}`)"

// each control
data-cmd-i="1" data-cmd-q="?a=eyJpZCI6NDJ9"

The ids are rendered into the listener as an array, which makes it an allowlist: data-cmd-i is an index into a table your template wrote, so a tampered attribute can only ever select an command this page already offered — or nothing.

The important part is what did not move. Args are still encoded by url() at render time, on the server, which is why dcmd works with sealed(). A hand-rolled version that assembles the URL from el.dataset cannot: a browser has no signing key. It also needs a client-side base64 helper (btoa throws on non-Latin1 input) and gives up any way to tell from the template which endpoints a page can reach. Here nothing happens on the client but a string concatenation.

It is not free. Delegation is bigger until the listener's fixed cost amortises. Measured on the real rendered markup for the tour's pair of commands: 118 B per row inline, 82 B per row delegated, 155 B for the listener. The demo prints the break-even live as rows come and go — 6 rows for that pair.
rowCosttodos.commands.tsx
/** Bytes each style spends per row, measured on the real rendered markup. */
export const rowCost = (): {
  perRow: number;
  delegated: number;
  listener: number;
} => {
  const a =
    ` data-on:click="${cmd(toggle, { id: 1 })}"`.length +
    ` data-on:click="${cmd(remove, { id: 1 })}"`.length;
  // measured on the attributes dcmd() actually emits, both controls
  const attrs = (fn: typeof toggle | typeof remove) =>
    Object.entries(rows.for(fn, { id: 1 }))
      .map(([k, v]) => ` ${k}="${v}"`.length)
      .reduce((x, y) => x + y, 0);
  return {
    perRow: a,
    delegated: attrs(toggle) + attrs(remove),
    listener: ` data-on:click="${rows.on}"`.length,
  };
};

The other cost does not amortise: a row's command becomes data rather than markup, so "what does this button do" is one indirection away. Worth it on long lists, not on three.

dcmd() takes function references only — it looks each one up in the registry to build the id table, and rows.for() rejects a function that is not in this delegate set. There is no string form.

Datastar options

The third parameter is passed straight through to the Datastar expression as DsOptions, a plain Record<string, unknown>. It is merged over anything cmd() derived, so an explicit option always wins over the one inferred from scope:

exprcommands.ts
function expr(m: string, ref: Ref, args?: unknown, opts?: DsOptions) {
  const { href, sig } = mint(ref, args);
  const merged = {
    ...(sig || requireHeader ? { headers: { [SIG_HEADER]: sig } } : null),
    ...derivedOpts(ref),
    ...opts, // explicit opts win
  };
  const has = Object.keys(merged).length > 0;
  return has ? `@${m}('${href}', ${js(merged)})` : `@${m}('${href}')`;
}

Options are serialised by js(), not JSON.stringify, because Datastar takes real regular expressions in filterSignals and JSON.stringify would flatten those to {}:

jscommands.ts
/** Serialise Datastar options to a JS expression — JSON.stringify would eat the regexes. */
function js(v: unknown): string {
  if (v instanceof RegExp) return v.toString();
  // Single quotes, to match the `@post('…')` around them — JSON.stringify would
  // mix `"` into an expression that is otherwise entirely single-quoted, and
  // every one of those becomes a `&quot;` once it is an attribute value.
  if (typeof v === "string")
    return `'${v.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
  if (Array.isArray(v)) return `[${v.map(js).join(", ")}]`;
  if (v && typeof v === "object") {
    return `{${Object.entries(v)
      // `Command-Signature` is not an identifier, so a bare key would emit
      // a subtraction and Datastar would fail to parse the expression.
      .map(([k, x]) => `${IDENT.test(k) ? k : `'${k}'`}: ${js(x)}`)
      .join(", ")}}`;
  }
  return JSON.stringify(v);
}

Args come first in the signature, so to reach the options of an command that takes no args you pass undefined for them:

cmd(add, undefined, { filterSignals: { include: /^(todo|user)\./ } })
// → @post('/command/todo.add', {filterSignals: {include: /^(todo|user)\./}})

When no options are derived and none are given, the expression is emitted bare — @post('/command/todo.remove?a=eyJpZCI6MX0') — rather than with an empty object trailing it.