Actions and the session
An action is a declared mutation
A loader reads. An action writes. It lives in actions.ts beside the page and is declared, not merely exported, so the build knows its input type and can put it in the contract.
import { action, fail } from "@snapfire/fsr";
export const pay = action<{ amount: number }>(async ({ input, identity, services }) => {
if (!identity) {
fail("unauthorized", "sign in to pay");
}
return services.ledger.pay({ invoice: input.invoice, amount: input.amount });
});The browser calls it through a generated, typed handle:
import { actions } from "@generated/client";
await actions.invoice.pay({ amount: 1200 });There is no URL to build and no body to serialise. The build knows the id, the input type and the return type, and a call that does not typecheck does not compile.
Guards run before anything opens
A guard that reads nothing external is evaluated before any service socket is opened, so an invalid call never leaves the server. fail(kind, message) is a statement, never an expression, and its kind maps onto a status:
| Kind | Status |
|---|---|
unauthorized | 401 |
not_found | 404 |
invalid | 400 |
conflict | 409 |
timeout | 408 |
unavailable | 503 |
internal | 500 |
The kind is a string literal, because the build matches on it. The message is any expression and is evaluated only when the guard fires, so a guard can say what it knows:
fail("not_found", `there is no post ${params.slug}`);Revalidation
An action that succeeds re-runs the loaders whose data it invalidated, and the pages and layouts holding that data re-render in place. A layout takes its new props without losing its DOM, so a cart count follows a mutation without the search box beside it losing what was typed.
The session and identity
The session is a typed record the host keeps, keyed by the cookie named in [session]. It survives a reload: the host rebuilds its tables and swaps them, and nobody is signed out.
Its end never slides. ttl is how long a new session lasts, one number written on the record that the cookie's Max-Age and the store both follow, so a session read a thousand times still ends one ttl after it opened. Moving it is a decision the application makes:
session.extend(7200);An action, a route handler or middleware may call it. A loader may not; the build refuses it by name, since a loader runs on every navigation and extending there would be a store write per page view. Extend on sign-in, on a write the application was making anyway or once the time left drops under a threshold.
identity is separate and is what [auth] produced. A loader or an action sees it as Identity | null and guards on it without ever seeing a password. That separation is what lets a mounted site guard its own routes against the shell's sign-in.
[session]
key = "..."
ttl = "24h"
[auth]
provider = "file"
login = "/login"Note that a mounted site's own [session] and [auth] are ignored: it uses the shell's. The boot report lists them under ignored so the drop is visible rather than silent.
The keys are a ring rather than one value. previous_keys holds the ones that still verify, so a rotation is a reload rather than a restart: the new key signs, the old ones verify until you take them out and nobody is signed out on the way. Every other part of [session] is refused on reload, because the store outlives it.
The lab
Write an action whose guard fails, call it from the browser and watch the network tab: the request comes back 401 without the service behind it ever being contacted. Then move the guard below the service call and watch the socket open.