Handlers
A handler is a typed function that runs on the server. Components call it as if it were local, a form posts to it, and with a route it is also a plain HTTP endpoint. You write it with defineHandler in any file under server/, and you export it from server/index.ts. The client bundle gets a small stub that posts the input and returns the answer. There is no directive and no compiler magic beyond that: a handler is a named export, and an import of #server from client code becomes the stub. The pages that use handlers are Data and Forms and login; the server side of the same file is Server.
Defining a handler
A handler is export const <name> = defineHandler({ … }), written with an object literal, because the build reads the options to make the stub. It sits in any file under server/, with any name. It is reachable only when server/index.ts exports it; a handler that is written and not exported has no id, no endpoint and no stub.
import { defineHandler, fail } from "silgi/server";
import { db } from "./utils/todos.ts";
import { idInput, schema } from "./utils/schema.ts";
const newTodo = schema<{ title: string }>(
{ type: "object", properties: { title: { type: "string", minLength: 1 } }, required: ["title"] },
(value) => {
const title = (value as { title?: unknown } | undefined)?.title;
return typeof title === "string" && title ? { value: { title } } : { issues: [{ message: "A todo needs a title", path: ["title"] }] };
},
);
export const listTodos = defineHandler({
method: "GET",
handler: () => db.all(),
});
export const addTodo = defineHandler({
input: newTodo,
handler: ({ title }) => db.add(title),
});
export const removeTodo = defineHandler({
input: idInput,
handler({ id }) {
if (!db.remove(Number(id))) return fail(404, { message: "That todo is already gone" });
return { removed: Number(id) };
},
});
// server/index.ts
export { addTodo, listTodos, removeTodo } from "./todos.ts";
| Option | Default | |
|---|---|---|
method | "POST" | "GET" or "POST" for a call from a component; any other method needs a route |
route | a public URL for the handler, a string literal; see Server | |
raw | false | with a route, a plain HTTP endpoint that reads event.request itself |
status, meta, events | with a route, the status of a success, the OpenAPI operation, and the data of server-sent events; see Server | |
websocket | with a route, a WebSocket; see Server | |
input | a Standard Schema; without it the handler gets the decoded value as sent | |
output | a Standard Schema, run on the value the handler returns on every call; what it returns is what leaves. A fail() skips it, and a refusal is a masked 500 | |
errors | { 409: schema, … }, the data of each error status the handler throws; never run. isSilgiError(error, 409, handler) types error.data from it | |
middleware | a list of handler middleware | |
cache | { maxAge, swr?, staleMaxAge?, getKey?, base?, maxResolveTime?, varies? }, method: "GET" only (Cache) | |
prerender | true or { inputs?: () => I[] }, method: "GET" only (Prerender) | |
batch | false | true makes calls of one tick one request, POST only |
form | the limits and onFile of a form or multipart body (Uploads) | |
handler(input, ctx) | the function; may be async, or an async generator for a stream |
ctx is { event, signal, context, clientContext }. event is the request, signal is event.request.signal and aborts when the caller goes away, context is the merged result of the server middleware, and clientContext is what client middleware sent, which the caller controls. The shape is the same on every path in: a call from a component, a route, a form and a raw handler all get handler(input, ctx).
A combination the build can see fails it, with the file and the export in the message: server/todos.ts: export "y": cache needs method "GET". defineHandler throws the same TypeErrors when it runs. A file that server/index.ts re-exports from must define each exported name as const name = defineHandler({ … }); export const a = wrap(defineHandler(…)) or a constant in the chain fails with export "a" is not defined with defineHandler.
Id and name
A handler has three names. The id is the first 16 hex characters of the SHA-256 of #server#<name>, where <name> is the name the door exports it under. It is in the client bundle and is not a secret. The name is that exported name, addTodo, which getCallContext and the function:error hook use. The label is todos.ts#addTodo, the file below server/ and the local name, which logs and spans show. Moving a definition to another file changes nothing a client sees; renaming the export changes the id, and a tab opened before the deploy gets a 404 on its next call.
The one door
server/index.ts is the surface of the server: its named exports are what the client can reach, and its default export, defineServer, is the rest (Server). #server is the door of the root that holds the importing file, so a layer's page reads the layer's server/index.ts. Adding a handler is two steps, the definition and the export, and a forgotten export is a build error that says what to write:
app/pages/p.jsx imports b from "#server", which server/index.ts does not export; add it: export { b } from "./<file>.ts"
An export that no client code reaches and that has no route has no endpoint in a production build, and the build warns: server/index.ts exports u1, which no client code reaches and which has no route: it has no endpoint. silgi dev keeps every export live. A test that calls an endpoint directly gives the handler a route, or imports it in a page.
Calling a handler
Import it from #server in a component, a page, or any client code, and call it:
import { useAction } from "@silgi/react";
import { useState } from "react";
import { addNote, countdown } from "#server";
export default function Handlers() {
const add = useAction(addNote, { onError: (error) => console.warn(error.message) });
const [left, setLeft] = useState<number | null>(null);
async function start() {
for await (const n of await countdown(5)) setLeft(n);
}
return (
<main>
<button disabled={add.pending} onClick={() => void add.run({ text: "Milk" }).catch(() => {})}>
Add a note
</button>
{add.error ? <p role="alert">{add.error.message}</p> : null}
<button onClick={() => void start()}>Count down</button>
{left === null ? null : <p>{left}</p>}
</main>
);
}
- In the browser a call runs the client middleware, then posts the input to
<baseURL>_silgi/fn/<id>and returns the value of the answer. Input is serialized as page payloads are, soDate,Map,Set,BigIntand the other payload types survive. AFile,BloborFormDatainside the input goes as a multipart part. The optional second argument is{ signal, headers, event }. - On the server the call runs middleware, validation and the handler in process: no HTTP request, no serialization, no client middleware.
addTodo(input, { event })runs on that event; withouteventthe call runs on the request running now (useEvent()), and with no request running it throwsTypeError: todos.ts#addTodo called on the server outside a request; pass { event }. A page importing#serverloads no handler until it calls one. - Types. The stub's type is
Handler<I, O>:(input, init?) => Promise<O>, withid,urlandmethod.inputis optional whenIacceptsundefined. A call with the wrong input type failstsc.silgi typegenwrites the types of#serverfrom the handlers' sources.
useAction(fn, options) keeps the pending state, the last value, the last fail() and the last error of calls from a component. It returns { run, state, failure, pending, error, reset }. Calls of one hook run one after another. After a success the page is read again (useRouter().refresh(): the load files and the live useAsyncData entries), unless refresh: false or the handler is a GET; pending stays true until that finished. A handler can answer the reads that refresh makes (Fresh data). run rejects when the handler fails, so a call that does not read error ends with .catch(() => {}). A fail() resolves run with the failure and sets failure; a redirect is not an error. The options are initial, onSuccess(value), onError(error), refresh and instance, which keeps one store per (handler, instance) so one handler can sit in a list of forms.
Errors and redirects on the client
Every answer carries x-silgi-fn. The stub reads it: value resolves; failure resolves with the fail() value; error rejects with a SilgiError that has the status, message and data; redirect starts a navigation and rejects with the SilgiRedirect (isRedirect(error) is true). An answer without the header, such as a 403 from the origin check or an answer of server middleware, rejects with an error made from its JSON body and status.
A thrown SilgiError reaches the caller as one, with its status and data. Any other error is masked as in Errors: a 500 Internal Server Error in a build, with the real message in dev and in the server log.
A handler that declares errors: { 409: schema } lets the caller read that data typed: if (isSilgiError(error, 409, createPost)) error.data?.conflictId. The schemas are not run, so data is what the handler threw, or undefined when it threw none; a status the handler does not declare leaves data as unknown. The guard works on what a call of the handler rejects with, in a component, on the server and in useAction(fn).error: a call during SSR fails as the same call over the wire does, with the function:error hooks and the masking. A $fetch of a handler's route rejects with a FetchError instead, and useFetch's error is not narrowed, since its data crossed JSON. With input, a refused input also answers 400, with data: { issues }.
Validation
With input, the input is validated as readValidatedBody does it, after the middleware: issues answer 400 Validation failed with data: { issues }. The caller gets that SilgiError, so a form can show error.data.issues. A handler called from a form also gets values, and useForm(fn).fields reads both (Forms and login). Without input the handler gets whatever was sent; its TypeScript parameter type is not a check.
output checks the other end. A handler that returns a row with a column it should not send can run its answer through output, and what the schema returns is what leaves.
Middleware
Handler middleware wraps the validation and the handler. defineMiddleware({ server, client }) has two optional parts. It is exported from silgi/app, because the file that holds it is imported by client code, and silgi/server is not allowed there. The same function is exported from silgi/server.
import { defineMiddleware } from "silgi/app";
import { requireUser } from "../utils/auth.ts";
export const auth = defineMiddleware<{}, { user: string }>({
async server({ event, next }) {
return next({ context: { user: await requireUser(event) } });
},
async client({ next }) {
return next({ sendContext: { locale: navigator.language } });
},
});
- A
serverpart runs on the server, in list order, and gets{ event, input, context, next }.next({ context })mergescontextinto the context (shallow) and resolves to the result of the rest of the chain. Returning withoutnextanswers with that value; a throw is the handler's error. Callingnext()twice throwsnext() called more than once. defineHandler({ middleware: [auth] })typesctx.contextas the mergedOutof its list, soctx.context.usercompiles only whenauthis listed.- A
clientpart runs in the stub, before the request, in the same order.next({ headers })adds request headers;next({ sendContext })sends values asx-silgi-context, at most 4096 bytes encoded (elseTypeError: sendContext of todos.ts#addTodo is larger than 4096 bytes). They arrive asctx.clientContextand are never merged intocontext. A client part that returns withoutnextresolves the call with that value and sends no request. Client parts do not run for a call made on the server. handlerMiddlewareindefineServeris a list that runs before the handler's own, for every handler of that door:
import { defineMiddleware } from "silgi/app";
import { setHeader } from "silgi/server";
export const timing = defineMiddleware({
async server({ event, next }) {
const start = performance.now();
try {
return await next();
} finally {
setHeader(event, "x-handler-ms", String(Math.round(performance.now() - start)));
}
},
});
A door's list governs its own handlers and what it re-exports from another door: the app's list runs outside a layer's. Folders mean nothing: moving a file never changes what guards a handler.
Where it may live. A middleware with a client part is imported by client code, so its file must hold only imports and export const <name> = defineMiddleware(…). The build drops each server part and the imports only it used from the client bundle. A file that mixes in other code fails the build and names the line; move the server code to a file it imports. An entry of handlerMiddleware must be an imported name, because the stub embeds each client part.
A request middleware, in the middleware list of defineServer, runs before every request, handler calls included. getCallContext(event) tells it which handler the request is about, so it can refuse one:
import { createError, defineMiddleware, getCallContext, getCookie } from "silgi/server";
export const removeGuard = defineMiddleware(({ event }) => {
if (getCallContext(event)?.name === "removeTodo" && !getCookie(event, "session")) throw createError({ status: 401 });
});
getCallContext returns { name, calledFrom: "fetch" | "form" | "route" }, or undefined for any other request. name is the handler's name; calledFrom is "form" for a document POST with ?_fn=<id>, "route" for a request to its route, and "fetch" for the stub's call.
Streaming, files and responses
What the handler returns decides the answer:
| Handler returns | The caller gets |
|---|---|
| a value | the value, status 200 or the one set with setResponseStatus |
fail(status, data) | the failure, isActionFailure(value) true, status 400 to 499 |
an AsyncIterable<T> or a ReadableStream<T> | an AsyncIterable<T> over text/event-stream |
a File or Blob | a File or Blob with the same bytes and type |
a Response | that Response, unread |
redirect(to), returned or thrown | a navigation, and a rejected call |
| a throw | a rejected call |
import { defineHandler } from "silgi/server";
export const countdown = defineHandler({
async *handler(from: number, { signal }) {
for (let n = from; n >= 0 && !signal.aborted; n--) {
yield n;
await new Promise((resolve) => setTimeout(resolve, 200));
}
},
});
A stream sends one event per chunk, data: <chunk>, then event: end; a throw midway sends event: error with the masked error and closes. A heartbeat comment goes out every 15 seconds. The caller iterates with for await (const n of await countdown(5)): the call resolves with the iterable. Breaking out of the loop or aborting init.signal cancels the request, and the server calls the iterator's return(), so a generator's finally runs at its next yield. A handler that waits on something long passes ctx.signal to it.
A File, Blob or FormData in the input goes as multipart/form-data and arrives with its original bytes. A returned File comes with content-disposition: attachment; filename*=UTF-8''<name>.
import { defineHandler } from "silgi/server";
export const checksum = defineHandler({
async handler({ file }: { file: File }) {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
},
});
export const report = defineHandler({
handler: () => new File(["id,title\n"], "todos.csv", { type: "text/csv" }),
});
export const upload = defineHandler({
form: { maxBodySize: 16 * 1024 * 1024, maxFileSize: 5 * 1024 * 1024, maxFiles: 3 },
async handler({ photos }: { photos: File[] }) {
return { names: photos.map((photo) => photo.name), bytes: photos.reduce((sum, photo) => sum + photo.size, 0) };
},
});
A returned Response goes out as it is (status, headers, body, set-cookie), except for the marker header x-silgi-fn: response. The stub fetches with redirect: "manual", so a 3xx resolves to the 3xx Response itself; in a browser that is the opaqueredirect response, which the stub also resolves unread.
Cancellation
The stub's init.signal aborts the request. On the server ctx.signal aborts when the client goes away, on the Bun and Node presets within milliseconds. Pass ctx.signal to the fetch or the query the handler awaits.
Cache and batches
import { defineHandler } from "silgi/server";
export const serverTime = defineHandler({
method: "GET",
cache: { maxAge: 60 },
handler: () => new Date(),
});
export const getWeather = defineHandler({
batch: true,
handler: (cities: string[]) => cities.map((city) => ({ city, celsius: 10 + city.length })),
});
cache with method: "GET" stores the answer, keyed by the host, the handler, the serialized input, the varies values and the x-silgi-context header. It takes the options of the cache: maxAge, swr (default false here), staleMaxAge, base, maxResolveTime and varies, and getKey(input, event) to widen the entries of one handler. A fill past maxResolveTime answers 504. The answer carries cache-control: public, max-age=<maxAge>, plus , stale-while-revalidate=… with swr, and vary: x-silgi-context. Two GETs within maxAge run the handler once. The middleware and the handler run on a request with no cookie, authorization or other header the caller sent, unless varies names it, so a cached answer is the anonymous answer. When varies names cookie or authorization the header is private, …. An answer that sets a cookie, is not 2xx, fails, or is a redirect, stream, file or Response is not stored. The endpoint caches; a call made on the server in process does not. A GET handler sends its input as ?input= and must not change state: it can be cached, prefetched and triggered by an <img>. A raw handler cannot have cache: a raw answer is not stored.
batch: true makes the handler take an array: handler(inputs: I[]): O[]. The stub stays (input: I) => Promise<O>. Calls made in one macrotask go out as one POST with x-silgi-batch: <n>, at most 100 inputs per request, and each caller gets its item in order. Every call still runs its own middleware and validation, so a call refused there fails alone. A handler that returns another number of results than inputs fails every caller with 500, and the server logs Batch function weather.ts#getWeather returned 9 results for 10 inputs. Ten getWeather(city) calls in one render make one request. A batch handler called without the header, or in process, runs the handler with one input. A handler with a route cannot be a batch: a route answers one call.
Prerender
prerender: true or prerender: { inputs: () => [...] } on a method: "GET" handler (else the build fails with export "y": prerender needs method "GET") adds to the answers a static build writes, and caches the handler on server presets. Every GET handler a page calls during the build gets its files, with or without prerender. The prerender records every call the pages make during the build, adds the inputs inputs() returns, and writes each answer to _silgi/fn/<id>/<16 hex of SHA-256 over the serialized input>.json in .output/public. In the static build the browser stub reads that file with a GET and never posts; an input with no file rejects with a SilgiError 404. A POST handler has no file: the build warns Prerender: posts.ts#postRead ran while prerendering /p, but it is a POST handler and a static site has no server: the page renders, and a refresh or a new input fails. Declare it method: "GET". once per handler, and its stub rejects before any request with a SilgiError 405 posts.ts#postRead is a POST handler; a static site has no server. The answers are the anonymous ones, computed like a cached handler's. An input whose call throws or redirects gets no file: the build logs a warning and the page gets the 404.
On a server preset the handler is a cached GET whose cache defaults to { maxAge: 31536000 }, keyed by the build. The stub adds the build id to the URL (&_b=<id>), so a new deploy is a new URL; silgi dev answers no-store. A cache of its own replaces the default.
Forms
Form action={fn} posts to a handler, with JavaScript through the stub and without it to the page. Forms and login has the input rules, the result and the no-JavaScript path. A handler used for a form has a POST method.
Fresh data in one round trip
A handler that changes what the page shows can send the next read with its answer. refresh(event, fn, input) from silgi/server takes a GET handler and its input: after the mutation it runs that handler in process and puts the value in the answer, so the client's next fn(input) sends nothing. renamePost in server/posts.ts above calls it with getPost. Each value is used once and the next navigation drops what is left. A fail(), a throw or a redirect runs no read, and a read that throws adds nothing and is logged, so the client reads it itself. refresh takes a GET handler and throws TypeError: refresh() takes a GET handler; <label> is POST for another. The answer carries cache-control: private, no-store, because its body depends on the caller. refresh does nothing on a request to a handler's route, which has no page.
import { createError, defineHandler, refresh } from "silgi/server";
import { comments, posts } from "./utils/posts.ts";
import { idInput, schema } from "./utils/schema.ts";
export const listPosts = defineHandler({
method: "GET",
handler: () => [...posts.values()],
});
export const getPost = defineHandler({
method: "GET",
route: "/api/posts/:id",
input: idInput,
handler({ id }) {
const post = posts.get(id);
if (!post) throw createError({ status: 404, message: "Post not found" });
return post;
},
});
export const getComments = defineHandler({
method: "GET",
route: "/api/posts/:id/comments",
input: idInput,
handler: ({ id }) => comments.get(id) ?? [],
});
const rename = schema<{ id: string; title: string }>(
{ type: "object", properties: { id: { type: "string" }, title: { type: "string", minLength: 1 } }, required: ["id", "title"] },
(value) => {
const { id, title } = value as { id?: unknown; title?: unknown };
if (typeof id !== "string" || typeof title !== "string" || !title) return { issues: [{ message: "A title is required", path: ["title"] }] };
return { value: { id, title } };
},
);
export const renamePost = defineHandler({
input: rename,
async handler({ id, title }, { event }) {
const post = posts.get(id);
if (!post) throw createError({ status: 404, message: "Post not found" });
posts.set(id, { ...post, title });
await refresh(event, getPost, { id });
return { id, title };
},
});
Layers and modules
A layer has its own server/index.ts, and a page of the layer reads it as #server. The app takes one of a layer's handlers by re-exporting it, with the layer's name as a door specifier:
// server/index.ts
export { getPost } from "#server/base";
export { same as sameFromB } from "#server/b";
A layer's name is the name in its config, else the basename of its folder. A layer's door is its whole surface: export "hidden" from "#server/base": that door does not export it. A re-export is not a new handler: the app and the layer reach one id. The app's handlerMiddleware runs outside the layer's for every handler the app re-exports, because one id has one chain.
A module has no server/index.ts. It calls addServer(file, { root? }) from silgi/kit with a file read like a door, and meta.name of the module becomes the door specifier. Modules has the call.
Errors, logs and spans
The server hook function:error(error, { id, name, event }) sees every error of a call before masking. A SilgiError it returns is what the caller gets; any other return leaves the masking. The server error hook gets the original with phase: "function". With tracing on, each call opens a span function <label> with the attribute silgi.function. The request log writes the label where it writes the path, and silgi dev prints [silgi] function todos.ts#addTodo 201 12ms.
Every handler is a public endpoint
Every handler that client code imports, or that has a route, can be called by anyone who can reach POST <baseURL>_silgi/fn/<id>. The id is in the client bundle. Being imported only from an admin page protects nothing.
- Authorize inside, in the handler or in its middleware, on every call.
requireUser(event)in the sampleauthmiddleware is the pattern. - Validate with
input. Without it the handler gets whatever the caller sent, whatever its TypeScript parameter type says. - Return only what the caller may see. Whatever the handler returns goes to the caller;
outputnarrows it. clientContextand every header are written by the caller.sendContextis not a place for identity.- What Silgi stops. The origin check refuses a cross-site form-style
POSTwith 403, the 415 rule refuses any content type other than the two form types and JSON, a body aboveserver.maxBodySizeor the handler'sformlimit answers 413, and an error that is not aSilgiErroris masked. Cross-site JSON needs a CORS preflight that no route grants unless acorsrule says so. - What it does not stop. Same-site pages, scripts with stolen cookies, and clients that are not browsers. A GET handler must not change state.
- A route middleware or a
loaddoes not guard data. They run in the browser, and a user can skip them; see Sessions and security.
Import guards
Handlers rely on the import guards of the client build. The build fails, naming the file, the reason and the chain of imports, when client code reaches a .server file, anything under a server/ folder (except through #server, which becomes stubs), a folder named server or .server, silgi/server, a node: or bun: specifier, a module that imports server-only, or a specifier in build.importProtection.client.deny. serverOnly(), clientOnly() and isomorphic() from silgi/app drop the other side's code from a file both sides import. The rules and messages are in the API reference.
Configuration
| Key | Default | |
|---|---|---|
server.maxBodySize | 1 MiB | bytes; a body above it answers 413 Request body is too large. A handler's form.maxBodySize replaces it for that handler |
build.importProtection.client.deny | [] | strings and RegExps whose import fails the client build |
build.importProtection.server.deny | [] | the same for the server build |