Created
August 14, 2026 14:33
-
-
Save derekr/3337d21e329224c4922d5e710ec036af to your computer and use it in GitHub Desktop.
server-actions.ts — 2026-08-14 snapshot — signature carriers, group(), async renderables. Latest: https://gist.github.com/derekr/90babab40b39f0439b42f1690e0b421f
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ───────────────────────────────────────────────────────────────────────────── | |
| // 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 `"` 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