Server actions
A file under src/actions/ runs on the server, always. Every function it
exports can be called from client code by importing it.
// src/actions/todos.ts
import { db } from '../lib/db';
export async function addTodo(text: string) {
const todo = await db.todos.insert({ text });
return todo;
}
// src/pages/index.tsx
import { addTodo } from '../actions/todos';
export const page = { mode: 'hybrid', title: 'Todos' };
export default function Todos() {
return <button onClick={() => addTodo('milk')}>Add</button>;
}
That is the whole feature. No endpoint to write, no fetch to hand-roll, no route to keep in sync with a client call site. The import is type-checked against the real function, so renaming an argument is a compile error at the call site rather than a 400 at runtime.
What the browser actually gets
Not the module. During the build, any import that lands in src/actions/ is
replaced with a generated stub before the bundler opens the file:
export function addTodo(...args) {
return callAction('todos#addTodo', args);
}
The real module is never read for a browser bundle, so a database URL, an API
key or a node:fs import inside an action file cannot reach the client through
any path: not through a constant, not through a transitive import, not by
accident. This is the reason actions live in their own directory rather than
being marked with a directive inside a page file: the boundary is a location,
which is easy to see and impossible to get half-right.
The server bundle imports the real module, and that import is what registers the action. An action file that nothing builds is not a live endpoint.
Naming
An action's id is its file path plus its export name:
| File | Export | Id |
|---|---|---|
src/actions/todos.ts |
addTodo |
todos#addTodo |
src/actions/admin/users.ts |
ban |
admin/users#ban |
Ids are derived, not generated, so they are stable across builds and readable in a network tab. Renaming a file or an export changes the id, which is the correct behaviour: it is a different function.
Only named function exports become actions. A default export is not an
action, and a non-function export (a constant, a schema) is skipped rather
than exposed.
Arguments and return values
Arguments and return values cross the wire as JSON, so they must be
JSON-serializable. A Date arrives as a string; a Map, a Set or a File
does not survive at all. For file uploads, use an API route
with multipart/form-data.
An action returning undefined yields undefined at the call site.
Errors
Throw an HttpError and the caller receives that status and message:
import { notFound, badRequest } from '@celsian/vura-core';
export async function getTodo(id: string) {
if (!id) throw badRequest('id is required');
const todo = await db.todos.find(id);
if (!todo) throw notFound('No such todo');
return todo;
}
try {
await getTodo('');
} catch (err) {
err.status; // 400
err.code; // 'BAD_REQUEST'
err.message; // '[vura] todos#getTodo: id is required'
}
Any other thrown error is logged on the server and reaches the client as a
generic 500. That is deliberate: an unexpected error's message routinely
contains a connection string or a file path, and an action is called from a
browser. If you want the caller to see it, make it an HttpError.
Security
Actions are a browser-to-server call, and the endpoint is guarded accordingly.
Same-origin only. A request must carry Sec-Fetch-Site: same-origin, or an
Origin matching the request host. A request with neither is rejected, because
a browser always sends one: a request without them is a script, and a script
should be calling an API route.
JSON only. The endpoint requires content-type: application/json, which an
HTML form cannot send cross-site without a preflight.
CSRF token. The client fetches a token from GET /__vura/action, which also
sets it as an HttpOnly cookie; every call sends the token in a header and the
server compares the two. Over HTTPS the cookie uses the __Host- prefix, so a
sibling subdomain cannot write it.
Arguments must be a JSON array, are size-capped, and an unknown id returns 404 without echoing what was asked for.
None of this authenticates the caller. An action is reachable by anyone who can load your site, exactly like an API route. Check the session inside the action, or guard the page with middleware:
export async function deleteTodo(id: string) {
const user = await currentUser();
if (!user) throw unauthorized('Sign in first');
...
}
Where actions run
| Target | Server actions |
|---|---|
vura dev |
Yes |
vura build + node dist/server/entry.js (Node) |
Yes |
| Docker / VPS | Yes |
| Cloudflare adapter | Not yet |
| Lambda adapter | Not yet |
The two adapters support API routes and server-rendered pages, but the action endpoint is not implemented yet. The same is true of middleware.
Calling an action from an event handler
The stub is an ordinary async function, so anything that awaits works:
import { signal } from 'what-framework';
import { addTodo } from '../actions/todos';
const status = signal('');
export default function Form() {
async function submit(e: Event) {
e.preventDefault();
status.set('saving');
try {
await addTodo(new FormData(e.target as HTMLFormElement).get('text') as string);
status.set('saved');
} catch (err: any) {
status.set(err.status === 400 ? 'check your input' : 'something broke');
}
}
return (
<form onSubmit={submit}>
<input name="text" />
<button type="submit">Add</button>
<span>{() => status()}</span>
</form>
);
}
The first action call in a session costs one extra request to fetch the CSRF token; it is cached for the rest of the page's life.