dispatch() runs and the refusals it makes, importing every command module at boot, and the two hooks that make a request observable. commands.ts has no dependencies — the transport, the trace sink and the routing around it are all yours.setupCommands({ sse })
setupCommands(opts: { sse: SseAdapter }): { defineCommand, sealed, cmd, dcmd, url, dispatch, loadCommands, registry, specFor, isSealed }One call at boot. It stores the SSE layer and hands back the API, so an app has a single place to import from:
import { ServerSentEventGenerator } from "@starfederation/datastar-sdk/web";
import { setupCommands } from "./commands";
export const { cmd, dcmd, defineCommand, dispatch, loadCommands } = setupCommands({
sse: ServerSentEventGenerator,
});let adapter: SseAdapter | null = null;
/**
* Configure the transport and get the API back in one call. 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
* cannot be handed a factory result.
*/
export function setupCommands(opts: {
sse: SseAdapter;
/** See `requireHeader` below — off by default. */
requireSignatureHeader?: boolean;
/** Encoded-args ceiling, enforced at render time. Defaults to 512 characters. */
maxArgs?: number;
}) {
adapter = opts.sse;
requireHeader = opts.requireSignatureHeader ?? false;
maxArgs = opts.maxArgs ?? 512;
return {
defineCommand,
group,
sealed,
cmd,
dcmd,
url,
dispatch,
loadCommands,
registry,
specFor,
isSealed,
};
}The suggested thing to inject is the Datastar SDK you already have. Nothing imports it — SseAdapter is declared structurally, which is the whole reason the module has zero dependencies.
loadCommands() self-register at import time — they call the module-level defineCommand() and can never be handed a factory result. Two registries would mean defineCommand() in one and cmd() in the other silently failing to resolve.Call it before anything can dispatch. If a request reaches dispatch() with no adapter configured, it throws rather than guessing:
if (!adapter) {
throw new Error(
"commands: no SSE adapter. Call setupCommands({ sse }) at boot — " +
"pass the Datastar SDK's ServerSentEventGenerator, or your own.",
);
}SseAdapter · SsePatch
Two methods is the entire contract. stream() opens a response and invokes the callback with something that can patch; readSignals() pulls the Datastar signal store off the request.
/**
* What the SSE layer provides. Declared structurally rather than imported, so
* this file has no dependencies at all — the Datastar SDK's
* ServerSentEventGenerator satisfies it as-is.
*/
export type SsePatch = {
patchElements(elements: string, options?: Record<string, unknown>): unknown;
patchSignals(signals: string, options?: Record<string, unknown>): unknown;
removeElements(
selector?: string,
elements?: string,
options?: Record<string, unknown>,
): unknown;
removeSignals(
keys: string | string[],
options?: Record<string, unknown>,
): unknown;
executeScript(script: string, options?: Record<string, unknown>): unknown;
};
/**
* The two things dispatch needs from an SSE layer. This is exactly the shape of
* the Datastar SDK's ServerSentEventGenerator, so the suggested setup is to hand
* the module straight over:
*
* import { ServerSentEventGenerator } from "@starfederation/datastar-sdk/web";
* export const { cmd, dcmd } = setupCommands({ sse: ServerSentEventGenerator });
*
* Anything else that can open a stream and read signals works too — a different
* runtime's SDK, a fake in tests, or your own writer.
*/
export type SseAdapter = {
stream(
onStart: (patch: SsePatch) => void | Promise<void>,
options?: Record<string, unknown>,
): Response;
readSignals(
req: Request,
): Promise<
| { success: true; signals: Record<string, any> }
| { success: false; error: string }
>;
};That is exactly the shape of the SDK's ServerSentEventGenerator, so it satisfies the type as-is. Anything else that can open a stream and read signals works the same way: a different runtime's SDK, your own writer, or a fake in tests. adapter.test.ts runs the whole dispatch path with no SDK imported at all, capturing patches into an array:
const emitted: string[] = [];
const fake: SseAdapter = {
stream(onStart) {
const patch: SsePatch = {
patchElements: (e, o) =>
emitted.push(`elements ${e}${o ? " " + JSON.stringify(o) : ""}`),
patchSignals: (s) => emitted.push(`signals ${s}`),
removeElements: (sel) => emitted.push(`remove ${sel}`),
removeSignals: (k) => emitted.push(`rmsignals ${k}`),
executeScript: (s) => emitted.push(`script ${s}`),
};
open = Promise.resolve(onStart(patch));
return new Response("fake-stream", { headers: { "x-fake": "1" } });
},
async readSignals(req) {
return { success: true, signals: await req.json() };
},
};Handlers never see SsePatch directly. ctx.patch is the sugar over it — elements(), signals(), remove(), script() — with patch.raw still there for the options the sugar skips. See defineCommand() for the handler side.
dispatch(req)
dispatch(req: Request): Promise<Response | null>It claims everything under /command/ and returns null for anything else, so it composes into whatever server you already have rather than replacing it:
const res = await dispatch(req);
if (res) return res;
// ...your routesThe lifecycle, in order, with what each step refuses:
| Step | Result | Why |
|---|---|---|
| path not /command/… | null | Not ours. Fall through to your own routing. |
| decode the id | 400 | POST /command/% makes decodeURIComponent throw a URIError, which would otherwise escape dispatch entirely and 500. |
| look up the id | 410 | Unknown id means HTML older than the running code. 410 is the "reload me" signal a Datastar fetch-error handler can act on. |
| method is POST | 405 + allow: POST | Commands mutate. Prefetchers, crawlers, <img> and link scanners all issue GETs. |
| same-origin check | 403 | Allowlist of Sec-Fetch-Site, with Origin cross-checked where present. |
| sealed signature | 403 | Only for sealed() commands. Also 403 when a sealed command is called with no signed args at all. |
| decode + validate args | 422 | Undecodable, absent-but-required, or failing the args schema. |
| read + validate input | 422 | Unreadable signals, or failing the input schema. |
| run the handler | 200 SSE | The only success path: every response is a stream. |
| anything thrown | 500 | Logged with console.error; the body is a flat "command failed". |
POST only. The reason is not REST purity. Commands mutate, so a GET that reaches one is a mutation triggered by a prefetcher or a crawler — and cmd.get would otherwise put sealed capability tokens into URLs that leak through history and Referer.
CSRF is an allowlist, not a denylist. A missing Sec-Fetch-Site — old Safari, webviews, every non-browser client — must fail, and same-site is not same-origin: a sibling subdomain is not us.
const site = req.headers.get("sec-fetch-site");
const origin = req.headers.get("origin");
const sameOrigin =
(site === "same-origin" || site === "none") &&
(origin === null || origin === u.origin || origin === publicOrigin(req, u));Sec-Fetch-Site is a forbidden header name, so Headers.set() silently no-ops on it and even same-page JavaScript cannot forge it.Input comes from signals, and only from signals. The adapter reads the Datastar store off the request; a declared scope picks the subtree the handler asked for.
const rawInput = spec.scope ? (signals[spec.scope] ?? {}) : signals;
const input = spec.input ? await check(spec.input, rawInput, "input") : undefined;One response path. The handler runs inside the stream callback and every patch enqueues into a body the browser already has — one request, many patches. A caller that cannot read that stream is refused before any of it happens:
if (!(req.headers.get("accept") ?? "").includes("text/event-stream")) {
return new Response("SSE response required", { status: 406 });
}ctx.form, no ctx.sse, and no patch sink. The cost is progressive enhancement: with scripting off, nothing here works at all. The gain is one transport, one response shape, one place input comes from.Invalid is exported, so a handler can refuse with the same 422 the boundary uses: throw new Invalid("input: …"). Every other throw is a 500.
datastar-fetch error branch.loadCommands(dir, pattern?)
loadCommands(dir: string, pattern = "**/*.commands.{ts,tsx}"): Promise<string[]>A POST can land on a process that never rendered the page, so every command module must be imported at boot on every instance. Importing is what registers; nothing else populates the registry. That is the entire job the "use server" directive was doing, and a filename does it 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();
}It returns the sorted relative paths it imported, which is worth printing at boot — a missing file here is a 410 later. One line, before you serve anything:
// Every command module must be imported at boot on every instance — a POST can
// land on a process that never rendered the page. This is the whole job the
// "use server" directive was doing.
const files = await loadCommands(import.meta.dir);Bun.Glob. Drop it and import your command modules by hand and the rest of the file is plain Node.Observability, and why none of it is here
The module has no logging API. No ctx.log, no setRecorder, no Level, no patch-recording proxy. That was a deliberate removal: everything a request trace needs is reachable through seams an application already owns, and a vendored file should not carry someone else's logging vocabulary.
| You want | Where it comes from |
|---|---|
| What the handler said | An ordinary import, correlated with AsyncLocalStorage — no threading through Ctx. |
| What went back to the client | Wrap the patch inside the SseAdapter you inject. You supplied the transport, so you can instrument it. |
| Why a request was refused | The status and body of the Response. dispatch already puts the reason there — stale command: x, bad args signature, input: id must be a number. |
| How long it took | Time your own call to dispatch. |
| "After the handler, stream still open" | The adapter again — it is still inside the stream callback when onStart resolves. |
This demo does exactly that in trace.ts. The adapter wraps the patch on the way in and regains control on the way out:
/**
* Wrap an SSE layer so the trace can see both directions of a request.
*
* The adapter is the right place for this because the adapter opened the
* stream: it can instrument the patch object on the way in, and it still holds
* an open stream after the handler resolves, which is the only moment something
* can patch on the handler's behalf.
*/
export function tracingAdapter(
sdk: SseAdapter,
afterHandler: (patch: SsePatch) => void,
): SseAdapter {
return {
stream: (onStart, opts) =>
sdk.stream(async (patch) => {
const s = als.getStore();
await onStart(s ? recording(patch, s.patches) : patch);
flush(200);
afterHandler(patch);
}, opts),
readSignals: async (req) => {
const r = await sdk.readSignals(req);
if (r.success) noteSignals(r.signals);
return r;
},
};
}onStart callback resolves. Anything that patches after the handler must happen inside that callback — which is why the hook lives in the adapter and not after your dispatch() call, where the stream would already be closed and the patches would vanish silently./**
* Run one request inside a trace scope.
*
* A refused request never reaches a handler, so nothing inside the stream can
* record it — but the reason is already the status and body of the Response,
* which is all a trace line needs.
*/
export async function traced(
req: Request,
run: () => Promise<Response | null>,
): Promise<Response | null> {
const u = new URL(req.url);
const id = u.pathname.startsWith("/command/")
? decodeURIComponent(u.pathname.slice("/command/".length))
: u.pathname;
return als.run(
{
id,
url: u.pathname + u.search,
lines: [],
patches: [],
signals: {},
t0: performance.now(),
},
async () => {
const res = await run();
if (!res || !u.pathname.startsWith("/command/")) return res;
const streamed = res.headers
.get("content-type")
?.includes("event-stream");
if (streamed) return res; // already flushed inside the stream
// Signals-only: a non-stream response from /command/ is always a refusal,
// because every path that reaches a handler answers over the stream.
if (!id.startsWith("log.")) {
store().setLastUrl(u.pathname + u.search);
store().pushLog({
level: "refusal",
command: id,
msg: await res.clone().text(),
payload: { status: res.status },
dur: `${(performance.now() - (als.getStore()?.t0 ?? 0)).toFixed(1)}ms`,
status: res.status,
});
}
return res;
},
);
}TRUST_PROXY · publicOrigin
Behind a reverse proxy the app sees http://localhost:3000 while the browser sends Origin: https://app.example.com, so the naive origin === u.origin check rejects every request. The public origin is reconstructed from the forwarded headers — but only when TRUST_PROXY=1, because those headers are attacker-controlled when nothing is in front of you, and trusting them by default would hand anyone a way to spoof both the origin and their rate-limit identity.
/**
* Behind a reverse proxy the app sees `http://localhost:3000` while the browser
* sends `Origin: https://app.example.com`, so a naive `origin === u.origin`
* check rejects every request. Reconstruct the public origin from the forwarded
* headers — but ONLY when TRUST_PROXY is set, because those headers are
* attacker-controlled when nothing is in front of you, and trusting them by
* default would hand anyone a way to spoof the origin (and the client IP).
*/
export const TRUST_PROXY = process.env.TRUST_PROXY === "1";
export function publicOrigin(req: Request, u: URL): string {
if (!TRUST_PROXY) return u.origin;
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
const proto = req.headers.get("x-forwarded-proto") ?? u.protocol.slice(0, -1);
return host ? `${proto}://${host}` : u.origin;
}http://localhost:3000 while the browser sent https://server-actions.exe.xyz. Set TRUST_PROXY=1 when — and only when — there is a proxy in front of you that overwrites X-Forwarded-*.The same flag is worth reading in your own code for anything else derived from those headers. The rate limiter here only believes X-Forwarded-For under the same condition, since otherwise one client could mint unlimited rate-limit identities.
registry() · specFor() · isSealed()
export const specFor = (fn: Command<any, any>) => specOf.get(fn) ?? {};
export const isSealed = (fn: Command<any, any>) =>
Boolean(specOf.get(fn)?.sealed);
export const registry = () => new Map(byId);registry() returns a copy of the id → function map, so iterating it cannot mutate registration. Printing it at boot is a cheap sanity check that discovery found what you expected:
for (const id of registry().keys()) console.log(` ${id}`);export and "is an endpoint" are independent. Only defineCommand() registers. Exporting a helper so a template or a test can call it does not ship a route — which whole-file "use server" registration would. The registry is the honest list of what is reachable.specFor() gives back the declared spec — args, input, scope, sealed — and isSealed() is the shorthand dispatch and url() use. Both take the function, not the id.
Running it in public
dispatch is a boundary, not a server. It authenticates nothing, rate-limits nothing, and caps nothing. What it does do is refuse cross-origin and non-POST requests, verify sealed signatures, and validate against your schemas — everything below is still yours:
| Concern | Where it lives |
|---|---|
| rate limiting | A token bucket per IP in server.ts, before dispatch is called at all. |
| sessions | A cookie read in server.ts; handlers reach their store through AsyncLocalStorage, so no session parameter is threaded through Ctx. |
| body size | maxRequestBodySize on Bun.serve — the default is 128 MB and nothing here legitimately posts more than a form. |
| authorization | The handler. Args and signals are both client-supplied; sealed() attests provenance, never permission. |
| security headers | Set on the response dispatch returns, in server.ts. |
// ── abuse controls ───────────────────────────────────────────────────────────
// The demo is public and unauthenticated, so the only identity available is the
// connection. A token bucket per IP bounds request rate; the per-session caps in
// store.ts bound what any one visitor can make the server hold.
const RATE = { burst: 40, perSec: 8, sweepMs: 60_000 };
const buckets = new Map<string, { tokens: number; at: number }>();
function allow(ip: string): boolean {
const now = Date.now();
const b = buckets.get(ip) ?? { tokens: RATE.burst, at: now };
b.tokens = Math.min(
RATE.burst,
b.tokens + ((now - b.at) / 1000) * RATE.perSec,
);
b.at = now;
if (b.tokens < 1) {
buckets.set(ip, b);
return false;
}
b.tokens -= 1;
buckets.set(ip, b);
return true;
}Because dispatch returns a plain Response, the app decorates it like any other — the session cookie and the security headers are applied to whatever comes back, refusals included:
const COOKIE = "sa_sid";
const readCookie = (req: Request) =>
req.headers
.get("cookie")
?.split(";")
.map((c) => c.trim().split("="))
.find(([k]) => k === COOKIE)?.[1];
const SECURITY_HEADERS = {
"x-content-type-options": "nosniff",
"referrer-policy": "same-origin",
"x-frame-options": "DENY",
};One more thing worth setting: COMMAND_KEY. Unset, each process generates a random key and warns loudly at boot — safe, but sealed URLs then break across restarts and across instances. See the guide for the full note.