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";
OptionDefault
method"POST""GET" or "POST" for a call from a component; any other method needs a route
routea public URL for the handler, a string literal; see Server
rawfalsewith a route, a plain HTTP endpoint that reads event.request itself
status, meta, eventswith a route, the status of a success, the OpenAPI operation, and the data of server-sent events; see Server
websocketwith a route, a WebSocket; see Server
inputa Standard Schema; without it the handler gets the decoded value as sent
outputa 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
middlewarea list of handler middleware
cache{ maxAge, swr?, staleMaxAge?, getKey?, base?, maxResolveTime?, varies? }, method: "GET" only (Cache)
prerendertrue or { inputs?: () => I[] }, method: "GET" only (Prerender)
batchfalsetrue makes calls of one tick one request, POST only
formthe 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, so Date, Map, Set, BigInt and the other payload types survive. A File, Blob or FormData inside 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; without event the call runs on the request running now (useEvent()), and with no request running it throws TypeError: todos.ts#addTodo called on the server outside a request; pass { event }. A page importing #server loads no handler until it calls one.
  • Types. The stub's type is Handler<I, O>: (input, init?) => Promise<O>, with id, url and method. input is optional when I accepts undefined. A call with the wrong input type fails tsc. silgi typegen writes the types of #server from 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 server part runs on the server, in list order, and gets { event, input, context, next }. next({ context }) merges context into the context (shallow) and resolves to the result of the rest of the chain. Returning without next answers with that value; a throw is the handler's error. Calling next() twice throws next() called more than once.
  • defineHandler({ middleware: [auth] }) types ctx.context as the merged Out of its list, so ctx.context.user compiles only when auth is listed.
  • A client part runs in the stub, before the request, in the same order. next({ headers }) adds request headers; next({ sendContext }) sends values as x-silgi-context, at most 4096 bytes encoded (else TypeError: sendContext of todos.ts#addTodo is larger than 4096 bytes). They arrive as ctx.clientContext and are never merged into context. A client part that returns without next resolves the call with that value and sends no request. Client parts do not run for a call made on the server.
  • handlerMiddleware in defineServer is 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 returnsThe caller gets
a valuethe 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 Bloba File or Blob with the same bytes and type
a Responsethat Response, unread
redirect(to), returned or throwna navigation, and a rejected call
a throwa 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 sample auth middleware 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; output narrows it.
  • clientContext and every header are written by the caller. sendContext is not a place for identity.
  • What Silgi stops. The origin check refuses a cross-site form-style POST with 403, the 415 rule refuses any content type other than the two form types and JSON, a body above server.maxBodySize or the handler's form limit answers 413, and an error that is not a SilgiError is masked. Cross-site JSON needs a CORS preflight that no route grants unless a cors rule 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 load does 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

KeyDefault
server.maxBodySize1 MiBbytes; 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