Skip to content

Instantly share code, notes, and snippets.

@derekr
Last active August 14, 2026 14:33
Show Gist options
  • Select an option

  • Save derekr/90babab40b39f0439b42f1690e0b421f to your computer and use it in GitHub Desktop.

Select an option

Save derekr/90babab40b39f0439b42f1690e0b421f to your computer and use it in GitHub Desktop.
server-actions.ts — "use server" ergonomics for Datastar, entirely at runtime. Zero deps, one file. Demo: https://server-actions.exe.xyz
// ─────────────────────────────────────────────────────────────────────────────
// server-actions.ts — "use server" ergonomics for Datastar, entirely at runtime.
//
// Vendor it:
// curl -fsSL https://gist.githubusercontent.com/derekr/90babab40b39f0439b42f1690e0b421f/raw/server-actions.ts -o server-actions.ts
//
// One file, ZERO dependencies. 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";
// export const { sa, dsa } = setupServerActions({ sse: ServerSentEventGenerator });
//
// Bun is assumed only for `Bun.Glob` in loadActions() — drop that one function
// and the rest is plain Node.
//
// The whole mechanism is:
// 1. a registry: id -> fn, and fn -> id (so templates can reference either)
// 2. a renderer: sa(fn, args) -> "@post('/action/<id>?a=<args>')"
// 3. a dispatcher: POST /action/:id -> look up fn, decode+validate, run, stream SSE
//
// There is no client bundle to erase a function body from, which is the only
// reason "use server" needs a compiler in React. So none of this needs one.
//
// export const remove = action("post.remove", { args: type({ id: "number" }) },
// async ({ args, patch }) => { … });
//
// <button data-on:click={sa(remove, { id: post.id })}>delete</button>
//
// Exports: setupServerActions, action, group, sealed, sa (+ .get/.url/.form),
// dsa, url, dispatch, loadActions, registry, SIG_HEADER/SIG_FIELD,
// ActionsIn/ActionMap (typed string refs).
//
// Set ACTION_KEY in the environment before using sealed(); without it each
// process generates a random key, so sealed URLs break across restarts and
// instances. Validation is Standard Schema — bring ArkType, Zod 4 or Valibot.
//
// MIT. No warranty. Read the security notes in the README before shipping
// sealed() for anything that carries real capability.
// ─────────────────────────────────────────────────────────────────────────────
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
/**
* A hardcoded fallback key is a forgery oracle: anyone who can read this source
* can mint valid sealed tokens (demonstrated — viewer→admin from the browser
* console in three lines). Generate a random one instead, and say so loudly. The
* cost is that sealed URLs do not survive a restart and do not work across
* instances — which is exactly the configuration you must not ship anyway.
*/
const KEY =
process.env.ACTION_KEY ??
(() => {
const k = randomBytes(32).toString("base64url");
console.warn(
"\n ⚠ ACTION_KEY is unset — using a random per-process key.\n" +
" Sealed action URLs will break on restart and across instances.\n" +
" Set ACTION_KEY to a shared secret before deploying.\n",
);
return k;
})();
// ── validation, via Standard Schema ──────────────────────────────────────────
// Structural, so this file depends on no validator. ArkType, Zod 4, Valibot and
// friends all expose `~standard`; the demo happens to use ArkType.
type StandardResult<T> =
{ value: T; issues?: undefined } | { issues: readonly { message: string }[] };
// snip:standard-schema-iface
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;
};
};
// /snip
type Out<S> = S extends StandardSchemaV1<any, infer O> ? O : undefined;
type In<S> = S extends StandardSchemaV1<infer I, any> ? I : undefined;
async function check<S extends StandardSchemaV1>(
schema: S,
value: unknown,
where: string,
): Promise<Out<S>> {
const r = await schema["~standard"].validate(value);
if (r.issues)
throw new Invalid(`${where}: ${r.issues.map((i) => i.message).join("; ")}`);
return r.value as Out<S>;
}
export class Invalid extends Error {}
// ── types ────────────────────────────────────────────────────────────────────
/**
* 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 { sa, dsa } = setupServerActions({ 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 }
>;
};
/**
* Anything a template produces. `{ html }` is this repo's JSX; a custom
* `toString` is Hono JSX, Hono's `html` tag, and most other string-building
* template layers; and any of those may arrive as a Promise, because an async
* component is a promise of one.
*
* The `toString` arm is structurally wide — every object has a `toString`, so
* the type alone cannot tell a template node from a Todo. `render()` closes that
* at runtime: a value still carrying `Object.prototype.toString` is a mistake,
* and throws instead of patching `[object Object]` into the page.
*/
export type RenderNode =
string | { html: string } | { toString(): string | Promise<string> };
// 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;
};
/** Resolve a template node to markup. See Renderable for why this is a runtime check. */
async function render(node: Renderable): Promise<string> {
const n = await node;
if (typeof n === "string") return n;
if (n && typeof n === "object") {
if ("html" in n && typeof n.html === "string") return n.html;
if (Array.isArray(n))
throw new TypeError(
"patch.elements(): got an array — spread it: patch.elements(...nodes)",
);
if (n.toString !== Object.prototype.toString) return await n.toString();
}
throw new TypeError(
"patch.elements(): not renderable — expected a string, an object with " +
`.html, or one with its own toString; got ${Object.prototype.toString.call(n)}`,
);
}
/**
* The sugar, plus the drain that makes it safe.
*
* A node can be a promise now, so a patch is no longer instantaneous — and two
* un-awaited calls would race, letting a synchronous `signals()` overtake an
* async `elements()`. Every call is therefore queued onto one chain and lands in
* call order whether or not the handler awaits it.
*
* `drain()` is the other half: the SSE layer closes the stream the moment the
* handler resolves, so dispatch waits for the queue before letting that happen.
* A failed patch does not reject at its call site — an un-awaited call would
* become an unhandled rejection — so the first failure is kept and rethrown by
* drain, inside the stream, where dispatch can log it.
*/
function sugar(raw: SsePatch): { patch: Patch; drain: () => Promise<void> } {
let tail: Promise<void> = Promise.resolve();
let failure: unknown;
const queue = (fn: () => unknown): Promise<void> => {
tail = tail.then(fn).then(
() => {},
(e) => void (failure ??= e),
);
return tail;
};
return {
drain: async () => {
await tail;
if (failure) throw failure;
},
patch: {
raw,
elements: (...nodes) =>
queue(async () => {
for (const n of nodes) await raw.patchElements(await render(n));
}),
signals: (values, options) =>
queue(() =>
raw.patchSignals(
typeof values === "string" ? values : JSON.stringify(values),
options,
),
),
remove: (selector) => queue(() => raw.removeElements(selector)),
script: (js, options) => queue(() => raw.executeScript(js, options)),
},
};
}
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
* action modules discovered by loadActions() self-register at import time and
* cannot be handed a factory result.
*/
export function setupServerActions(opts: {
sse: SseAdapter;
/** See `requireHeader` below — off by default. */
requireSignatureHeader?: boolean;
}) {
adapter = opts.sse;
requireHeader = opts.requireSignatureHeader ?? false;
return {
action,
group,
sealed,
sa,
dsa,
url,
dispatch,
loadActions,
registry,
specFor,
isSealed,
};
}
export type Ctx<A = undefined, I = undefined> = {
/** Render-time arguments baked into the URL by sa(). Client-visible; forgeable unless sealed. */
args: A;
/** Validated per-request input — signals under Datastar, form fields on the no-JS path. */
input: I;
/** Raw. Every non-underscore Datastar signal rides along with every request for free. */
signals: Record<string, any>;
/** Raw. Present only for urlencoded posts (no-JS <form>, or contentType:'form'). */
form?: Record<string, string>;
req: Request;
/** Patches back to the client. A no-op sink on the no-JS path. */
patch: Patch;
/** False when the caller is a plain browser form post rather than Datastar. */
sse: boolean;
};
export type ActionFn<A = undefined, I = undefined> = (
ctx: Ctx<A, I>,
) => unknown | Promise<unknown>;
/**
* A registered action. The phantom `__args` makes `sa(fn, args)` typecheck at the
* call site; `__id` carries the id as a literal type so a string reference can be
* checked just as tightly.
*/
export type Action<
A = undefined,
I = undefined,
Id extends string = string,
> = ActionFn<A, I> & {
readonly __args?: A;
readonly __id?: Id;
};
/**
* The id -> args map for string references. Empty here; the app augments it, so
* this file never imports feature modules (which would be a cycle). See registry.ts:
*
* declare module "./actions" {
* interface ActionMap extends ActionsIn<typeof todos & typeof admin> {}
* }
*/
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ActionMap {}
/**
* 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 an action:
* the phantom brands are optional, so a plain view export like `summarize()`
* structurally satisfies Action with Id inferred as the wide `string`, which
* would otherwise collapse the whole map into an index signature.
*/
export type ActionsIn<M> = {
[
K in keyof M as M[K] extends Action<any, any, infer Id>
? string extends Id
? never
: Id
: never
]: M[K] extends Action<infer A, any, any> ? A : never;
};
type Spec = {
args?: StandardSchemaV1;
input?: StandardSchemaV1;
/**
* Signal namespace this action reads, e.g. "signup" for `data-bind:signup.email`.
* Does double duty: dispatch hands the handler that subtree as `ctx.input`, and
* sa() derives a `filterSignals` from it so the client stops shipping the whole
* signal store on every click. On the no-JS form path the flat fields are used
* instead, so the handler sees one shape either way.
*/
scope?: string;
sealed?: boolean;
/**
* Actions in a group() patch that group's read model once the handler returns.
* Set false for a handler that streams its own sequence of patches, where a
* trailing whole-view patch would land on top of the last step it sent.
*/
autoPatch?: boolean;
};
const byId = new Map<string, Action<any, any>>();
const idOf = new WeakMap<Action<any, any>, string>();
const specOf = new WeakMap<Action<any, any>, Spec>();
const groupOf = new WeakMap<
Action<any, any>,
() => Renderable | Renderable[]
>();
// ── registration ─────────────────────────────────────────────────────────────
/** Ids end up inside a single-quoted JS string in a data-on attribute. */
const SAFE_ID = /^[A-Za-z0-9._-]+$/;
/**
* A scope is checked harder than an id, because it is interpolated into a RegExp
* literal that is then inlined into an attribute — `/^${scope}\./` — as well as
* being read as a signal path. Letters, digits and underscore, leading letter.
*/
const SAFE_SCOPE = /^[A-Za-z][A-Za-z0-9_]*$/;
/**
* Register a function as a reachable endpoint.
*
* 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
* you can grep one string to find the definition, every call site, and every
* line in the access log.
*/
export function action<const Id extends string, A = undefined>(
id: Id,
fn: ActionFn<A, undefined>,
): Action<A, undefined, Id>;
export function action<const Id extends string, S extends Spec>(
id: Id,
spec: S,
fn: ActionFn<Out<S["args"]>, Out<S["input"]>>,
): Action<In<S["args"]>, Out<S["input"]>, Id>;
export function action(id: string, a: any, b?: any): any {
if (!SAFE_ID.test(id))
throw new Error(`unsafe action id: ${JSON.stringify(id)}`);
const [spec, fn] = (typeof a === "function" ? [{}, a] : [a, b]) as [
Spec,
Action,
];
if (spec.scope !== undefined && !SAFE_SCOPE.test(spec.scope))
throw new Error(`unsafe signal scope: ${JSON.stringify(spec.scope)}`);
if (byId.has(id)) throw new Error(`duplicate action id: ${id}`);
byId.set(id, fn);
idOf.set(fn, id);
specOf.set(fn, spec);
return fn;
}
/**
* Mark an action's args as a capability: signed on the way out, verified on the
* way in. Use for values the handler must trust without re-deriving (a resolved
* tenant, a price, a role). Ordinary args need no signature — the handler has to
* authorize them anyway, exactly as it must authorize signals.
*
* Orthogonal to the schema: the signature attests provenance, the schema attests
* shape. A token minted by last week's deploy verifies fine and can still be the
* wrong shape, so sealed args get validated too.
*/
export const sealed = <S extends Omit<Spec, "sealed">>(
spec: S,
): S & { sealed: true } => ({
...spec,
sealed: true,
});
// ── groups ───────────────────────────────────────────────────────────────────
/** A group supplies the scope, so an action inside one does not declare it. */
export type GroupSpec = Omit<Spec, "scope">;
export type Group<Name extends string> = {
readonly name: Name;
action<const Local extends string, A = undefined>(
local: Local,
fn: ActionFn<A, undefined>,
): Action<A, undefined, `${Name}.${Local}`>;
action<const Local extends string, S extends GroupSpec>(
local: Local,
spec: S,
fn: ActionFn<Out<S["args"]>, Out<S["input"]>>,
): Action<In<S["args"]>, Out<S["input"]>, `${Name}.${Local}`>;
};
/**
* One declaration doing three jobs: an id namespace, a signal scope, and the one
* read model that re-renders after a command succeeds.
*
* const cart = group("cart", () => <CartView />);
* export const add = cart.action("add", { input: Item }, ({ input }) => {
* store().addItem(input.name); // a command. No patch call.
* });
*
* That registers `cart.add`, scopes it to `$cart.*` — so sa() emits the
* filterSignals for it — and patches CartView once the handler returns. The
* handler becomes a pure command and the read model renders itself, which is the
* shape CQRS asks for and the shape most handlers in a feature were hand-rolling
* one `patch.elements(<Whole View />)` at a time.
*
* Only on the SSE path. A no-JS form post gets a 303 and the full page re-render
* stands in for the patch, so the two transports agree without patching twice —
* the group render and the redirect are the same idea on different transports.
*
* The patch happens INSIDE the stream callback, which is the only moment it can:
* the SDK closes the stream as soon as that callback resolves, so anything moved
* after it is silently dropped.
*/
export function group<const Name extends string>(
name: Name,
render: () => Renderable | Renderable[],
): Group<Name> {
if (!SAFE_SCOPE.test(name))
throw new Error(`unsafe group name: ${JSON.stringify(name)}`);
return {
name,
action(local: string, a: any, b?: any) {
const [spec, fn] = typeof a === "function" ? [{}, a] : [a, b];
const registered = (action as any)(
`${name}.${local}`,
{ scope: name, ...spec },
fn,
);
groupOf.set(registered, render);
return registered;
},
} as Group<Name>;
}
export const specFor = (fn: Action<any, any>) => specOf.get(fn) ?? {};
export const isSealed = (fn: Action<any, any>) =>
Boolean(specOf.get(fn)?.sealed);
export const registry = () => new Map(byId);
// ── 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());
/**
* The action id is signed alongside the body, so a token is only valid for the
* action it was minted for. Signing the body alone would mean: give two sealed
* actions a compatible arg shape — `refund.issue({amount})` and
* `payout.send({amount})` — and a token for the cheap one is a valid token for
* the expensive one. The id is length-prefixed so no two (id, body) pairs can
* collide into the same signed string.
*/
const mac = (id: string, body: string) =>
createHmac("sha256", KEY)
.update(`${id.length}:${id}.${body}`)
.digest("base64url");
/**
* A signature has one canonical message and three carriers. The message never
* changes — `len(id):id.body` — so an implementation in another language
* verifies a token whichever way it arrived; only the transport differs.
*
* header The default. Datastar sets it from the expression sa() emits, which
* keeps the MAC out of request URLs and therefore out of access logs,
* proxy logs and error reports. It also makes a cross-origin fetch
* trigger a CORS preflight that this server never answers, so the
* request is dropped before it is sent — CSRF protection that does
* not depend on Sec-Fetch-Site being present.
* field `<input type="hidden" name="_sig">`, for the no-JS form path, which
* cannot set a header. Deliberately NOT a query parameter: a form post
* navigates, so its action URL lands in the address bar and in session
* history — a worse leak than the one the header just closed.
* query `?a=<body>.<mac>`, what sa() used to mint. Still accepted, because
* HTML rendered by the previous deploy is open in a browser right now.
* Nothing emits it any more.
*/
export const SIG_HEADER = "Server-Action-Signature";
/** Reserved form field name. Stripped before the input ever reaches a schema. */
export const SIG_FIELD = "_sig";
/**
* Emit the signature header on every action, not just sealed ones — the value is
* empty for the rest. Buys the preflight-CSRF property above for the whole app,
* costs ~45 bytes of markup on every action expression. Off by default; worth it
* if you would rather pay bytes than trust `sec-fetch-site`, which old Safari and
* webviews do not send.
*/
let requireHeader = false;
type Minted = { id: string; href: string; sig: string };
/** Resolve a reference, encode its args, and sign them if the action is sealed. */
function mint(ref: Ref, args?: unknown): Minted {
const id = typeof ref === "string" ? ref : idOf.get(ref);
if (!id)
throw new Error(
`sa(): not a registered action (did you forget to wrap it in action()?)`,
);
const fn = byId.get(id);
if (!fn) throw new Error(`sa(): no action registered as "${id}"`);
if (args === undefined) {
if (isSealed(fn))
throw new Error(`sa(): "${id}" is sealed but was given no args`);
return { id, href: `/action/${id}`, sig: "" };
}
const b = enc(args);
return {
id,
href: `/action/${id}?a=${b}`,
sig: isSealed(fn) ? mac(id, b) : "",
};
}
// ── render side ──────────────────────────────────────────────────────────────
/**
* A reference is either the function itself or its id. Both resolve to the same
* args type, so `sa(remove, {id})` and `sa("todo.remove", {id})` are equally checked
* — and an unregistered id is a compile error, not a 410 at runtime.
*/
export type Ref = Action<any, any, any> | keyof ActionMap;
type ArgsOf<T> =
T extends Action<infer A, any, any>
? A
: T extends keyof ActionMap
? ActionMap[T]
: unknown;
/**
* Args are REQUIRED when the action declares an args schema, optional when it
* doesn't. Without this, `sa(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 SaParams<T> =
undefined extends ArgsOf<T>
? [args?: ArgsOf<T>, opts?: DsOptions]
: [args: ArgsOf<T>, opts?: DsOptions];
/**
* The bare URL. Use for `<form action>` and `<a href>`.
*
* Throws for a sealed action rather than handing back an unsigned URL that would
* 403 at click time: the signature no longer rides in the query string, so a
* sealed form needs `sa.form()`, which returns the hidden field alongside it.
*/
export function url<T extends Ref>(ref: T, ...rest: ArgsParam<T>): string {
const m = mint(ref, rest[0]);
if (m.sig)
throw new Error(
`url(): "${m.id}" is sealed and its signature does not ride in the URL. ` +
`Use sa.form() for a <form>, or sa() under Datastar.`,
);
return m.href;
}
/** 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)
// `Server-Action-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);
}
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
export type DsOptions = Record<string, unknown>;
/**
* sa() 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 action'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;
}
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}')`;
}
/** `data-on:click={sa(remove, {id})}` -> `@post('/action/todo.remove?a=eyJpZCI6MX0')` */
export const sa = Object.assign(
<T extends Ref>(ref: T, ...rest: SaParams<T>) =>
expr("post", ref, rest[0], rest[1]),
{
get: <T extends Ref>(ref: T, ...rest: SaParams<T>) =>
expr("get", ref, rest[0], rest[1]),
url,
/**
* The no-JS path: everything a <form> needs, in pieces rather than markup,
* so this module still knows nothing about any template layer.
*
* const f = sa.form(pay, { orderId });
* <form method="post" action={f.action}>
* {f.sig ? <input type="hidden" name={f.field} value={f.sig} /> : null}
*
* `sig` is "" for an unsealed action — render the field only when it is set.
*/
form: <T extends Ref>(ref: T, ...rest: ArgsParam<T>) => {
const { href, sig } = mint(ref, rest[0]);
return { action: href, sig, field: SIG_FIELD };
},
},
);
// ── delegation ───────────────────────────────────────────────────────────────
/**
* One listener for a whole list instead of one action expression per row.
*
* The naive version has the client assemble the URL from `el.dataset`, which
* costs you three things: a client-side base64 helper (which `btoa` breaks on
* non-Latin1 input), any way to know from the template which endpoints a page can
* reach, and sealed actions entirely — a browser cannot mint a signature.
*
* Only the *id* is worth deduplicating, so only the id moves to the listener, as
* a rendered allowlist. Args stay per-row but are still encoded by `url()` at
* render time, which means they carry their MAC and `dsa` works with `sealed()`.
* Nothing is assembled on the client but a string concatenation.
*
* const rows = dsa(toggle, remove);
* <div data-on:click={rows.on}>
* <button {...rows.for(toggle, { id: t.id })}>✓</button>
*
* Trade-off that remains: a row's action is data, not markup, so "what does this
* button do" is one indirection away. Worth it on long lists, not on three.
*/
export function dsa<const T extends readonly Action<any, any, any>[]>(
...actions: T
) {
const ids = actions.map((fn) => {
const id = idOf.get(fn);
if (!id) throw new Error("dsa(): not a registered action");
return id;
});
const table = `[${ids.map((i) => `'${i}'`).join(",")}]`;
// Only pay for the header on lists that actually need one. The signature is
// per row, so it rides in the row's dataset and the shared listener reads it.
const signs = actions.some(isSealed) || requireHeader;
const opts = signs
? `, {headers: {'${SIG_HEADER}': _t.dataset.saS || ''}}`
: "";
return {
/** Put on the container. The id can only ever be one of the rendered ids. */
on:
`let _t = evt.target.closest('[data-sa-i]'); ` +
`_t && @post(\`/action/\${${table}[_t.dataset.saI]}\${_t.dataset.saQ || ''}\`${opts})`,
/** Spread onto the row control. Args are encoded (and signed) right here. */
for<A extends T[number]>(fn: A, ...rest: ArgsParam<A>) {
const i = actions.indexOf(fn);
if (i === -1) throw new Error("dsa(): action not in this delegate set");
const { href, sig } = mint(fn, rest[0]);
const q = href.slice(
href.indexOf("?") === -1 ? href.length : href.indexOf("?"),
);
const row: Record<string, string> = {
"data-sa-i": String(i),
"data-sa-q": q,
};
if (sig) row["data-sa-s"] = sig;
return row;
},
};
}
// ── discovery ────────────────────────────────────────────────────────────────
// A POST can land on a process that never rendered the page, so every action
// 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.
// snip:discovery
export async function loadActions(
dir: string,
pattern = "**/*.actions.{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();
}
// /snip
// ── dispatch ─────────────────────────────────────────────────────────────────
/**
* 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;
}
/** A Patch that swallows everything, for the no-JS path where a redirect re-renders instead. */
const sink = sugar(new Proxy({}, { get: () => () => [] }) as SsePatch).patch;
export async function dispatch(req: Request): Promise<Response | null> {
const u = new URL(req.url);
if (!u.pathname.startsWith("/action/")) return null;
if (!adapter) {
throw new Error(
"server-actions: no SSE adapter. Call setupServerActions({ sse }) at boot — " +
"pass the Datastar SDK's ServerSentEventGenerator, or your own.",
);
}
// snip:stale
let id: string;
try {
id = decodeURIComponent(u.pathname.slice("/action/".length));
} catch {
// `POST /action/%` — decodeURIComponent throws URIError, which would
// otherwise escape dispatch entirely and 500.
return new Response("bad action id", { status: 400 });
}
const fn = byId.get(id);
// Unknown id means HTML older than the running code. 410 is the signal for
// "reload me", which a datastar-fetch error handler can act on.
if (!fn) return new Response(`stale action: ${id}`, { status: 410 });
// /snip
// Actions mutate, so GET must not reach one: prefetchers, crawlers, <img> and
// link scanners all issue GETs, and sa.get would otherwise put sealed
// capability tokens into URLs that leak via history and Referer.
if (req.method !== "POST") {
return new Response("method not allowed", {
status: 405,
headers: { allow: "POST" },
});
}
// Allowlist, not 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. Origin is cross-checked where present.
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));
if (!sameOrigin) {
return new Response("cross-origin", { status: 403 });
}
const spec = specOf.get(fn) ?? {};
const sse = (req.headers.get("accept") ?? "").includes("text/event-stream");
try {
// 1. body — read first, because a no-JS form carries its signature in a
// hidden field. Parsed but not yet validated, so a malformed payload still
// loses to a bad signature: 403 before 422, as before the carriers landed.
const ct = req.headers.get("content-type") ?? "";
let signals: Record<string, any> = {};
let form: Record<string, string> | undefined;
let signalsError: string | undefined;
if (ct.includes("form-urlencoded")) {
form = Object.fromEntries(new URLSearchParams(await req.text()));
} else {
const r = await adapter!.readSignals(req);
if (r.success) signals = r.signals;
else signalsError = r.error;
}
// `_sig` is the carrier, never the handler's input — strip it before any
// schema sees it, or a strict input schema rejects its own signature.
const fieldSig = form?.[SIG_FIELD];
if (form) delete form[SIG_FIELD];
// 2. args + signature — args from the URL, minted by sa() at render time;
// the signature from whichever carrier the caller could use.
let args: unknown = undefined;
const rawArgs = u.searchParams.get("a");
const dot = rawArgs === null ? -1 : rawArgs.indexOf(".");
const body =
rawArgs === null ? null : dot === -1 ? rawArgs : rawArgs.slice(0, dot);
const headerSig = req.headers.get(SIG_HEADER);
const carried = [
headerSig,
fieldSig,
dot === -1 ? null : rawArgs!.slice(dot + 1),
].filter((s): s is string => Boolean(s));
// Disagreeing carriers are a bug or an attempt to find the most permissive
// one. Picking a winner silently would let the weakest carrier decide.
if (new Set(carried).size > 1)
return new Response("conflicting signature", { status: 403 });
const sig = carried[0] ?? "";
// A custom header cannot be set on a cross-origin fetch without a preflight,
// and this server answers none — so requiring one is CSRF protection that
// does not rely on sec-fetch-site. Form posts are exempt because they cannot
// set headers; a cross-origin one is already `sec-fetch-site: cross-site`.
if (requireHeader && headerSig === null && form === undefined)
return new Response("signature header required", { status: 403 });
if (body !== null) {
if (spec.sealed) {
const want = mac(id, body);
// Compare BYTE lengths: a 43-char multibyte signature passes a string
// length check and then makes timingSafeEqual throw RangeError.
const sigBuf = Buffer.from(sig);
const wantBuf = Buffer.from(want);
const ok =
sigBuf.length === wantBuf.length && timingSafeEqual(sigBuf, wantBuf);
if (!ok) return new Response("bad args signature", { status: 403 });
}
try {
args = dec(body);
} catch {
throw new Invalid("args: not decodable");
}
if (spec.args) args = await check(spec.args, args, "args");
} else if (spec.sealed) {
return new Response("sealed action requires args", { status: 403 });
} else if (spec.args) {
// The compiler stops sa(remove) with no args; this stops a hand-built URL.
throw new Invalid("args: required but absent");
}
// 3. input — signals under Datastar, form fields on the no-JS path
if (signalsError) throw new Invalid(`signals: ${signalsError}`);
// Under Datastar, a scoped action reads its own signal subtree. A no-JS form
// posts flat field names, so those are used as-is — the handler sees one shape.
const rawInput =
form ?? (spec.scope ? (signals[spec.scope] ?? {}) : signals);
const input = spec.input
? await check(spec.input, rawInput, "input")
: undefined;
const ctx = { args, input, signals, form, req, sse } as Omit<
Ctx<any, any>,
"patch"
>;
// Plain browser form post: run the action, then 303 back so the full page
// re-render replaces the patches the client can't receive.
if (!sse) {
await fn({ ...ctx, patch: sink });
// Never reflect Referer into Location — that is an open redirect on our
// own origin. Keep only the path, and only if it really is our origin.
const ref = req.headers.get("referer");
const back = ref ? URL.parse(ref, u.origin) : null;
return new Response(null, {
status: 303,
headers: {
location:
back && back.origin === u.origin
? back.pathname + back.search
: "/",
},
});
}
// The adapter opened this stream, so the adapter is where an app hooks
// "after the handler, while the stream is still open" — see trace.ts.
return adapter!.stream(async (stream) => {
const { patch, drain } = sugar(stream);
await fn({ ...ctx, patch });
// The command ran; now the group's read model renders itself. Explicit
// patches from the handler are already queued, so this goes last.
const view = groupOf.get(fn);
if (view && spec.autoPatch !== false) {
const nodes = view();
patch.elements(...(Array.isArray(nodes) ? nodes : [nodes]));
}
// Nothing may be in flight when this callback resolves: the stream closes
// with it, and a patch that has not been written yet is simply lost.
await drain();
});
} catch (e) {
if (e instanceof Invalid) {
return new Response(e.message, { status: 422 });
}
console.error(`action ${id} failed:`, e);
return new Response("action failed", { status: 500 });
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment