silgi/server

Code that runs on the server: the files server/index.ts makes reachable, and server plugins. Everything uses Web standards and runs on every preset, except where noted. The guides are Server, Storage and cache and Sessions and security.

Handlers and the event

fail, isActionFailure, ActionFailure, FormDataOptions, FileUpload

The same functions as in silgi/app, for code under server/: fail(status, data) is a handler's failure with a 4xx status. FormDataOptions and FileUpload are the form option of defineHandler and the options of readMultipart. See silgi/app.

defineHandler, Handler, HandlerOptions, HandlerMethod, BatchHandlerOptions, HandlerCache, HandlerPrerender, HandlerContext, HandlerInit

Defines a handler: a typed function that pages call, that a URL can answer, and that a form posts to. It is export const <name> = defineHandler({ … }) with the options written as an object literal, in any file under server/; server/index.ts exports it. A name in the chain that is not defined that way fails the build with server/index.ts: export "y" is not defined with defineHandler. See Handlers for the guide.

Option
method"POST" (default) or "GET", written as a string. With a route, also "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE" or "QUERY", whose calls from the client still post; with raw, "*"
routea string literal such as "/v1/posts/:id": the handler also answers that public URL, read at build with nothing imported. Input is the path, the query and the body merged; a name sent twice with two values answers 400
rawwith route: a plain HTTP endpoint. The body stays unread on event.request, input is the path and the query, the value answers as a raw route's does, there is no client stub and no _silgi/fn endpoint
statuswith route: the success status, an integer literal from 200 to 299
metawith route: the OpenAPI operation, a HandlerMeta
eventswith route: a Standard Schema of one server-sent event's data; never run. It refuses output (events cannot be used with output: a route has one success body)
errors{ [status]: StandardSchema }, the data of each error status the handler throws, for isSilgiError(error, status, handler); never run. A key outside 400 to 599 throws errors key "200" must be a status from 400 to 599, a value that is not a Standard Schema errors[404] must be a Standard Schema
websocketwith route: the hooks and origin of a socket (WebSockets); no input, output, events, raw, status or handler
inputa Standard Schema; issues answer 400 Validation failed with data: { issues }; on a route each issue is { in, path, message }, with in the params, query or body its key came from. Without input the handler gets the decoded value as sent, whatever its parameter type says
outputa Standard Schema, run on the value the handler returns on every call: in process, over the endpoint and during SSR. What the schema returns is what leaves, so a schema that strips keys strips them, and O is the schema's type. A refusal answers a masked 500 and logs todo.ts#addTodo returned a value its output schema refuses: <message> at <path>; it is never a 400. A Response, Blob, stream, redirect or fail() skips it
middlewarea list of defineMiddleware results, after the handlerMiddleware of the doors that list it
cachea HandlerCache, method: "GET" only
prerendertrue or { inputs }, method: "GET" only
batchtrue, POST only; handler takes the array of inputs and returns one result per input
forma FormDataOptions: the body limit, maxFileSize, maxFiles and onFile of a form or multipart body for this handler, at the endpoint and on a form post to a page; without it the limit is server.maxBodySize and 10 files
handler(input, ctx)the function; may be async, or an async generator or return a ReadableStream for a stream
function defineHandler<I, O, M>(options: { method?: HandlerMethod; route?: string; input?: StandardSchemaV1<unknown, I>; output?: StandardSchemaV1<unknown, O>; middleware?: M; handler(input: I, ctx: HandlerContext<context of M>): O | Promise<O> }): Handler<I, Serialized<Awaited<O>>>   // a stream: AsyncIterable<Serialized<item>>; a Blob or Response as it is

interface Handler<I, O, Sent = unknown, Returned = unknown, Errors = {}> {      // Sent: what a route caller sends, the input schema's input type (RouteInput); Returned: the handler's return type, which FetchData reads; Errors: the errors option, which isSilgiError reads
  (input: I, init?: HandlerInit): Promise<O>;   // input is optional when I accepts undefined
  readonly id: string;
  readonly url: string;                                 // <baseURL>_silgi/fn/<id>
  readonly method: "GET" | "POST";
  readonly label?: string;                              // todos.ts#addTodo
}
interface HandlerInit { signal?: AbortSignal; headers?: HeadersInit; event?: SilgiEvent }
interface HandlerContext<C> { event: SilgiEvent; signal: AbortSignal; context: C; clientContext: Record<string, unknown> }
type HandlerMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | "CONNECT" | "TRACE" | "QUERY" | "*"
interface HandlerCache extends CacheOptions<[input: unknown, event: SilgiEvent]> { swr?: boolean; varies?: string[] }   // GET only
interface HandlerPrerender<I> { inputs?: () => I[] | Promise<I[]> }                                                    // GET only

// batch: true; the stub stays (input: I) => Promise<Serialized<O>>
function defineHandler<I, O, M>(options: { method?: "POST"; input?: StandardSchemaV1<unknown, I>; middleware?: M; batch: true; handler(inputs: I[], ctx): O[] | Promise<O[]> }): Handler<I, Serialized<Awaited<O>>>

cache (with method: "GET" only, else the build fails with export "y": cache needs method "GET") caches the answer in the cache storage, keyed by the host, the id, the serialized input, the varies header values and the x-silgi-context header, and answers cache-control: public, max-age=<maxAge> (private when varies names cookie or authorization; plus , stale-while-revalidate=<staleMaxAge, else 31536000> with swr: true) and vary: x-silgi-context[, <varies>], never with a set-cookie. The middleware and the handler then run on a request that carries no cookie, authorization or other header unless varies names it, with an empty event.context, so the cached answer is the anonymous one; an answer that sets a cookie, fails, is not 2xx, or is a stream, file, Response or redirect is not stored. cache takes maxAge, swr, staleMaxAge, base, maxResolveTime and varies as defineCachedHandler does, except that swr defaults to false. getKey(input, event) replaces the serialized input in the key: input is what was sent, unvalidated, and event is the cache fork, so an unlisted header is not on it. raw refuses cache (raw cannot be used with cache: a raw answer is not stored). A GET handler must not change state. prerender: true | { inputs } (with method: "GET" only, else export "y": prerender needs method "GET") makes the static preset's build write the answer of every call the prerendered pages made, and of each input inputs() returns, to _silgi/fn/<id>/<16 hex of SHA-256 over the serialized input>.json; under that preset the stub reads the file and never posts, and an input with no file rejects with a SilgiError 404. An input whose answer is an error or a redirect is skipped with a warning and gets no file. On a server preset the handler is a cached GET whose cache defaults to { maxAge: 31536000 } with the build id in its key. batch: true (POST only) collects the stub's calls made in one macrotask into one request with x-silgi-batch: <n> (at most 100 inputs; more go in further requests): each call runs the middleware and the validation for its own input, and the inputs that pass reach one handler call, which returns one result per input in order (another length fails those calls with 500). A call refused by middleware or validation fails alone; the body limit applies to the whole request. batch refuses a route (route cannot be used with batch: a route answers one call).

defineHandler throws a TypeError for a combination it cannot run: status needs route, meta needs route, events needs route, status must be an integer from 200 to 299, batch needs method "POST", middleware[0] of a server function must be defineMiddleware(…), and the others the options table implies. The build reports the same messages with the file and the export before them.

Import it from #server. In the client build the import is a stub that posts the input, serialized as payloads are (a File, Blob or FormData inside it goes as a multipart part), to <baseURL>_silgi/fn/<id> (a GET handler sends ?input=); it resolves to the handler's value, rejects with a SilgiError carrying the status for an error (anything but a thrown SilgiError is masked to 500 outside dev), and follows a returned or thrown redirect() with navigateTo and rejects with it. On the server the import is the function itself: addTodo(input, { event }) validates and runs the handler in process, without HTTP; without event it runs on the request of useEvent(), and with no request running it throws TypeError: todos.ts#addTodo called on the server outside a request; pass { event }. The id is 16 hex of SHA-256 over #server#<name>, <name> being the name server/index.ts exports it under, and it is in the client bundle: every handler is a public endpoint, so authorize inside it. A handler that returns an async iterable (an async function*) or a ReadableStream answers server-sent events, one per value; the stub resolves to an AsyncIterable of the values, and leaving the for await loop or aborting init.signal cancels the request and calls the iterator's return() (a generator's finally runs at its next yield, so pass ctx.signal to a long wait). A throw mid-stream rejects the loop with the masked error. A returned File or Blob arrives as one, with its bytes and type. A returned Response goes out as it is (status, headers, body, set-cookie) plus the header x-silgi-fn: response, and the stub resolves to it. A handler that returns or throws fail(status, data) answers that status with x-silgi-fn: failure, and its stub resolves to the ActionFailure; with output the handler may still return it, and the caller's type is the schema's type or the failure. A call's body is limited by server.maxBodySize or the handler's form, then 413; a POST whose content type is not a form, multipart or JSON answers 415; the origin check applies. A form post to the page with ?_fn=<id> (what Form action={fn} renders) runs the handler with the form decoded by formToObject and renders the page with the result; see Forms and login.

// server/todos.ts
export const addTodo = defineHandler({
  input: v.object({ title: v.string() }),
  async handler({ title }, { event }) {
    await requireUser(event);
    return db.todos.add(title);
  },
});

RouteParams, JsonSafe, JsonSerialized, NotSerializable

Types of a handler with route. RouteParams<R> is the union of the parameter names of the literal route R, and never for a route with none: RouteParams<"/v1/:org/posts/:id?"> is "org" | "id". JsonSafe<T> is T when a route's JSON answer carries it, else a type holding the message.

type RouteParams<R extends string> = /* parameter names, or never */;
type JsonSafe<T> = /* T, or a message */;
type JsonSerialized<T> = /* T as a route's JSON answer holds it */;
interface NotSerializable<Message extends string> { readonly refused: Message }

defineHandler returns such a message, which cannot be called, when a route parameter is missing from input or from the handler's input type, and when the handler of a non-raw route returns a value JSON cannot carry: Map, Set, RegExp, Error, a binary view, a bigint, a symbol, a function (so a class with methods), or undefined as an array item. The call then fails tsc, with the message in the error:

Type 'RefusedHandler<"route \"/v1/posts/:id\" has parameter \"id\", which input does not declare">' has no call signatures.

A value with toJSON (Date, URL), a Response, a Blob, a redirect and a stream of safe chunks pass. A class with fields only looks like a plain object to the types; the run-time check refuses it.

JsonSerialized<T> is the data $fetch gives for a non-raw route whose handler returns T, and NotSerializable the type of a value no wire carries; both are described with Serialized in silgi/app.

defineMiddleware, FunctionMiddleware

Defines a middleware; also exported from silgi/app. It takes a function, which is the server part, or an object with a server part, a client part, or both. It has three seats: the middleware list of a handler, the handlerMiddleware list of defineServer (around every handler of that door, before the handler's own list), and the middleware list of defineServer, which runs around every request. server parts run on the server, in list order: next({ context }) merges context into the context (shallow) and resolves to the result of the rest of the chain; returning without calling next answers with that value; returning nothing continues; a throw is the error of the call or the request. A handler reads the merged context as ctx.context, typed from its list. client parts run in the browser stub, in the same order, before the request: next({ headers }) adds request headers, and next({ sendContext }) merges values sent as the x-silgi-context header (at most 4096 bytes encoded, else the stub throws TypeError: sendContext of todos.ts#addTodo is larger than 4096 bytes) that the handler reads as ctx.clientContext. The caller writes clientContext, so it is untrusted: never put identity there. A call made on the server runs no client part and gets {}.

function defineMiddleware<In = {}, Out = {}>(definition: FunctionMiddleware<In, Out> | ServerStep<In, Out>): FunctionMiddleware<In, Out>

type ServerStep<In, Out> = (args: { input: unknown; context: In; event: SilgiEvent; next(options?: { context?: Out }): Promise<unknown> }) => unknown;   // undefined without next continues; a value answers
interface FunctionMiddleware<In = {}, Out = {}> {
  server?: ServerStep<In, Out>;
  client?(args: { input: unknown; context: Record<string, unknown>; next(options?: { headers?: HeadersInit; sendContext?: Record<string, unknown> }): Promise<unknown> }): Promise<unknown>;
}

In a handler's call input is the input as sent and context the context so far. In the middleware list of defineServer, input is undefined and context is event.context, so next({ context }) sets keys on event.context; code after await next() sees the answer and can set event.response.headers, and a returned Response replaces it. An entry may be { route, handler } to run under one path. A request step has no client part: defineServer refuses one in middleware (middleware[0] has a client part, which a request list never runs; list it in handlerMiddleware).

The client build drops each server part, and the imports only it used. A middleware with a client part is declared where the client may import it, importing defineMiddleware from silgi/app: a file that holds only imports and export const <name> = defineMiddleware(…). A middleware declared in the handler's own file fails the build.

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 } });
  },
});
import { defineMiddleware, getRequestHeader } from "silgi/server";

export const locale = defineMiddleware(({ event }) => {
  event.context.locale = getRequestHeader(event, "accept-language")?.split(",")[0] ?? "en";
});

getCallContext

Which handler the request calls, read from the URL and the id table, so a request middleware can refuse one. name is the handler's name, the name server/index.ts exports it under (removeTodo); calledFrom is "form" for a document POST to a page with ?_fn=<id>, "route" for a request to a handler's route, and "fetch" for the stub's call. Any other request gets undefined.

function getCallContext(event: SilgiEvent): { name: string; calledFrom: "fetch" | "form" | "route" } | undefined
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 });
});

refresh

Primes a read from inside a mutation. After the mutation's handler ends with a value, the GET handler fn runs in process with input, its middleware and validation included, and the answer carries the result. The client's next call of fn with that input, from useAsyncData, a load or plain code, takes the value and sends nothing. Each value is used once, and the next navigation drops what is left. An answer with reads is cache-control: private, no-store. A read that throws adds nothing and is reported; one that ends in a redirect, a fail() or a body that is not a value adds nothing. A fail(), a throw or a redirect from the mutation runs no read, and neither does a no-JS form post, whose render is the read. A handler that is not GET throws TypeError: refresh() takes a GET handler; <label> is POST.

function refresh<I>(event: SilgiEvent, fn: Handler<I, unknown>, input?: I): Promise<void>
// server/posts.ts
export const renamePost = defineHandler({
  async handler({ id, title }: { id: string; title: string }, { event }) {
    await db.posts.rename(id, title);
    await refresh(event, getPost, { id });
  },
});

EventHandler, HandlerFunction, HandlerObject

The event-form handler that defineCachedHandler takes and returns. It is not a route: a handler with a route is defineHandler.

type HandlerFunction<T> = (event: SilgiEvent) => T | Promise<T>
interface HandlerObject<T> {
  handler: HandlerFunction<T>;
  onRequest?: (event: SilgiEvent) => unknown;
  onBeforeResponse?: (event: SilgiEvent, response: Response) => Response | void | Promise<Response | void>;
}

onRequest runs first; a value other than undefined ends the request with it. onBeforeResponse gets the final response and may return another. EventHandler<T> is what defineCachedHandler returns: the object, branded. A raw handler calls its handler(event).

import { defineCachedHandler } from "silgi/server";
import type { EventHandler, HandlerFunction, HandlerObject } from "silgi/server";

const handler: HandlerFunction<{ ok: boolean }> = () => ({ ok: true });
const object: HandlerObject<{ ok: boolean }> = {
  handler,
  onBeforeResponse: (event, response) => {
    response.headers.set("x-done", "1");
    return response;
  },
};

export const route: EventHandler<{ ok: boolean }> = defineCachedHandler(object, { maxAge: 60 });

HandlerMeta, ValidationIssue

HandlerMeta holds the OpenAPI fields of a handler route's operation: summary, description, tags, operationId, deprecated, security, externalDocs, x-* keys, requestBody (replaces the generated one), responses (merged over the defaults by status) and hidden (left out of the document).

interface HandlerMeta {
  summary?: string; description?: string; tags?: string[]; operationId?: string; deprecated?: boolean; hidden?: boolean;
  security?: Array<Record<string, string[]>>; externalDocs?: { url: string; description?: string };
  requestBody?: Record<string, unknown>; responses?: Record<string, Record<string, unknown>>;
  [extension: `x-${string}`]: unknown;
}

ValidationIssue is one entry of data.issues in the 400 answer of a route: { in: "params" | "query" | "headers" | "body"; path: (string | number)[]; message: string }.

StandardSchemaV1, StandardJSONSchemaV1, StandardTypedV1

The Standard Schema interfaces (spec 1.1.0), as types. Any validator whose schemas implement them works in schema: Silgi calls ~standard.validate for validation and ~standard.jsonSchema for the document. A schema without jsonSchema still validates and is documented as {}.

getOpenAPIDocument, GetOpenAPIDocumentOptions, OpenAPIDocument, OpenAPIConfig

Builds the OpenAPI 3.2 document of a list of server routes, as the server serves it when server.openapi is on. Each call builds a fresh document; route modules are loaded once.

function getOpenAPIDocument(options: GetOpenAPIDocumentOptions): Promise<OpenAPIDocument>
interface GetOpenAPIDocumentOptions { routes: LazyServerRoute[]; baseURL?: string; config?: OpenAPIConfig }
interface OpenAPIConfig {
  path?: string;
  info?: { title: string; version: string; summary?: string; description?: string };
  servers?: Array<{ url: string; description?: string }>;
  [key: string]: unknown;
}

path is where the server answers, /_silgi/openapi.json by default. The other keys are merged over the document root: info and components key by key, tags by name, anything else replaced. openapi, paths and jsonSchemaDialect cannot be set.

SilgiEvent, SilgiEventContext, SilgiRuntimeEnv

The request, as every handler and load sees it.

FieldType
requestRequest
urlURL
methodstring
paramsRecord<string, string | string[]>route params
contextSilgiEventContext & Record<string, unknown>shared by middleware and the handler
response{ status; statusText?; headers: Headers }merged into the response you return
waitUntil(promise)voidkeeps the work alive after the response on platforms that need it
runtime{ name: string; env?: SilgiRuntimeEnv & Record<string, unknown> }the preset's name and its platform bindings
logLoggerthe server logger bound with the request id, made on first read
import { defineMiddleware, getRequestHeader } from "silgi/server";

export const locale = defineMiddleware(({ event }) => {
  event.context.locale = getRequestHeader(event, "accept-language")?.split(",")[0] ?? "en";
});
import { defineHandler } from "silgi/server";
import type { SilgiEvent } from "silgi/server";

function describe(event: SilgiEvent): string {
  return `${event.method} ${event.url.pathname} on ${event.runtime.name}`;
}

export const getUser = defineHandler({
  route: "/api/users/:id",
  method: "GET",
  raw: true,
  handler(_, { event }) {
    event.waitUntil(Promise.resolve(console.log(describe(event))));
    event.response.headers.set("x-handled", "yes");
    return { id: event.params.id, user: event.context.user };
  },
});

SilgiEventContext and SilgiRuntimeEnv are interfaces an app or module augments, so event.context.user and event.runtime.env?.DB have a type in every handler, in a loader's ctx.event, in the event a cached handler's getKey receives, in a WebSocket peer's context and in a GraphQL resolver's context.event. SilgiEventContext declares nonce?: string itself (the CSP nonce). Other keys stay unknown.

interface SilgiEventContext { nonce?: string }
interface SilgiRuntimeEnv {}

declare module "silgi/server" {
  interface SilgiEventContext { user?: { id: string } }
  interface SilgiRuntimeEnv { DB: { prepare(q: string): unknown } }
}

defineServer, ServerDefinition, RoutedMiddleware

The default export of server/index.ts: what the server is made of. The file holds only imports, re-exports and this call. plugins run once at start, in order. middleware runs around every request, in order; { route, handler } runs only under route. handlerMiddleware runs around every handler this file exports, on every call. Each list of a layer runs before the app's, and a handler a door re-exports runs that door's list outside its owner's.

function defineServer(definition: ServerDefinition): ServerDefinition

interface ServerDefinition {
  plugins?: ServerPlugin[];
  middleware?: Array<FunctionMiddleware | RoutedMiddleware>;
  handlerMiddleware?: FunctionMiddleware[];   // each an imported name: the client build reads this list
}
interface RoutedMiddleware { route: string; handler: FunctionMiddleware }

One middleware object works in both lists. A step returning undefined without calling next continues, a value answers, a throw is the error, and a thrown or returned redirect() answers the redirect. A client part in middleware fails: a request list never runs one.

definePlugin, ServerPlugin, SilgiServer, ServerHooks, ErrorContext, ErrorPhase

A server plugin in the plugins list of defineServer runs once when the server starts and receives { hooks, runtimeConfig, log, addLogSink, addSpanExporter, addMetricExporter }.

function definePlugin(plugin: ServerPlugin): ServerPlugin
type ServerPlugin = (server: SilgiServer) => void | Promise<void>
interface SilgiServer {
  hooks: Hooks<ServerHooks>;
  readonly runtimeConfig: SilgiRuntimeConfig & Record<string, unknown>;   // frozen, the SILGI_* overrides applied
  log: Logger;                                 // the server logger
  addLogSink(sink: LogSink): () => void;       // returns its remover
  addSpanExporter(exporter: SpanExporter): () => void;   // returns its remover
  addMetricExporter(exporter: MetricExporter): () => void;   // returns its remover
}

runtimeConfig is the same object useRuntimeConfig() returns without an event, frozen. On cloudflare, vercel and netlify plugins run at the first request, and the object is resolved from that request's env (cloudflare) or the process env before they run; on bun and node it is resolved at start.

addLogSink gets every record the server logger writes, after redaction. Its flush runs when the bun or node server drains, and on cloudflare, vercel and netlify through waitUntil after each request that wrote a record. addSpanExporter gets the ended spans of sampled requests in batches (see withSpan below); with tracing off it is never called. addMetricExporter gets a snapshot of every metric (see useMetrics below); with metrics off it is never called.

HookCalled with
request(event)
beforeResponse(event, response)
afterResponse(event, response)
error(error, event, context): context is an ErrorContext; a hook that takes two parameters keeps working
function:error(error, { id, name, event }): a handler's error, before masking; a returned SilgiError is what the caller gets (in the answer, or as the event: error of a stream)
client:report(report, event): a ClientReport the browser sent, once per accepted report (see ClientReport below); only with server.clientReports on
fetch:request({ request, event? }): before a server-side $fetch call is sent (the global $fetch, a loader's ctx.fetch, useFetch, and clients made from them with create); a returned Request replaces it for the later hooks and the call; a throw rejects the call. event is the event the client is bound to, absent for the global $fetch
fetch:response({ request, response, event? }): the answer, before its body is read
render:html(parts): htmlAttrs, head, bodyAttrs, bodyPrepend, body, bodyAppend
task:error(error, { name, trigger, scheduledTime? }): a task run nobody awaits failed (trigger is "schedule", "queue", "start" or "enqueue"); the run is logged at error first, and error is not called, since such a run has no event
close()
cloudflare:scheduled({ controller, env, context }): cloudflare preset; a Cron Trigger fired, after the scheduledTasks whose Cloudflare form equals controller.cron ran
cloudflare:queue({ batch, env, context }): cloudflare preset; for the tasks.queue queue after its messages' tasks ran and were acked or retried, for any other queue the consumer, which acks or retries the messages itself (with no listener such a batch throws queue "<name>" has no cloudflare:queue hook, so Cloudflare retries it); a rejected listener fails the batch
cloudflare:email({ message, env, context }): cloudflare preset; a mail Email Routing sent to the worker; the handler awaits the listeners, so setReject, forward and reply happen within it, and a listener that throws makes the delivery fail; with no listener the mail is rejected with No email handler
cloudflare:tail({ events, env, context }): cloudflare preset; the trace events of the workers whose tail_consumers name this one

The four cloudflare:* hooks run after the server plugins, with the runtime config resolved from that invocation's env; context is the Workers ExecutionContext, and, except for cloudflare:email, the promise of the listeners is handed to context.waitUntil, so the worker returns before they finish.

The error hook gets the raw thrown value, the event and an ErrorContext. The client only ever gets context.masked.

interface ErrorContext {
  phase: ErrorPhase;
  route?: string;                              // the matched server route pattern or page route path
  requestId: string;
  traceId?: string; spanId?: string;           // when the request is traced
  masked: { status: number; message: string }; // what the client got (or would get)
  handled: boolean;                            // false: no answer carries this error
}
type ErrorPhase = "request" | "middleware" | "route" | "loader" | "action" | "function" | "render" | "error-page"
  | "stream" | "websocket" | "graphql" | "cache" | "background" | "hook";

The hook also receives the errors that no answer carries: a rejected event.waitUntil promise (background), a throw in a beforeResponse, afterResponse or page:error hook (hook), a callback of an event stream (stream), a WebSocket hook error on a route without an error hook (websocket), a failed background refresh of a cache entry (cache), and a GraphQL resolver error the endpoint masked (graphql). Each of them has handled: false except the GraphQL one. An incomplete body, an aborted request and an error thrown inside an error hook never reach it. Each error that reaches the hook is logged once by the server logger first, at error (debug for a SilgiError below 500).

import { definePlugin } from "silgi/server";

export const timer = definePlugin((server) => {
  server.hooks.hook("request", (event) => {
    event.context.startedAt = performance.now();
  });
  server.hooks.hook("afterResponse", (event, response) => {
    const took = performance.now() - (event.context.startedAt as number);
    server.log.info("request", { path: event.url.pathname, status: response.status, ms: Math.round(took) });
  });
});
import { definePlugin } from "silgi/server";
import type { SilgiServer, ServerHooks, ServerPlugin } from "silgi/server";

const onError: ServerHooks["error"] = (error, event) => console.error(event.url.pathname, error);

const plugin: ServerPlugin = (server: SilgiServer) => {
  server.hooks.hook("error", onError);
  server.hooks.hook("close", () => console.log("bye"));
};

export default definePlugin(plugin);

useRuntimeConfig

The full runtime config of the server, the private keys and public, with the SILGI_<KEY> and SILGI_PUBLIC_<KEY> environment variables applied. The event is optional: without it you get the process's config, anywhere in server code, also at module scope (frozen). On cloudflare, vercel and netlify that config exists once the first request has arrived; before it the call throws useRuntimeConfig() needs the event before the first request on the cloudflare preset; pass the event, or read it in a server plugin (naming the preset). With an event it returns that event's config. It is server only: the browser receives public alone, through the adapter's useRuntimeConfig().

function useRuntimeConfig(event?: SilgiEvent): SilgiRuntimeConfig & Record<string, unknown>
import { defineHandler, useRuntimeConfig } from "silgi/server";

export const config = defineHandler({
  route: "/api/config",
  method: "GET",
  raw: true,
  handler(_, { event }) {
    const config = useRuntimeConfig(event);
    return { apiBase: (config.public as { apiBase?: string }).apiBase, hasSecret: typeof config.apiSecret === "string" };
  },
});

useAppConfig

The app config of app/app.config.ts over appConfig, in server code. Inside a request it is that request's own copy, made on first read through the request context (see useEvent) and also the config of the page render and payload.config.app, so a mutation in one request is never seen by the next. Outside a request, or with server.asyncContext: false, it is the frozen build object and a mutation throws a TypeError.

function useAppConfig(): ConfigShape<SilgiAppConfig>

useEvent

The event of the request running now, in code that was not handed it: a helper in server/utils/, a load, a handler, middleware, a handler route, an app plugin on the server and the page render. Every request runs inside the request context, an AsyncLocalStorage store the preset provides, so the event follows each request across await, timers, event.waitUntil and a body stream still being written; two requests at once never see each other's. An in-process $fetch is a new request, so code it reaches sees the inner event. Inside defineCachedHandler, a route-rule cache or a cached GET handler it is the cache fork, which carries only the varies headers; a defineCachedFunction getter runs on a fork with no request headers and an empty context. The event-less getCookie(name), setCookie(name, value, options?) and deleteCookie(name, options?) and a server function called on the server without { event } use it.

function useEvent(): SilgiEvent

It throws an Error (500 in production):

WhenMessage
no request runs: module scope, a server plugin, a timer started outside a request, a WebSocket hook after the upgradeuseEvent() was called outside a request
server.asyncContext: false and tracing offuseEvent() needs the request context; set server.asyncContext: true in silgi.config.ts
the runtime has no AsyncLocalStorage (cloudflare without nodejs_compat)useEvent() needs AsyncLocalStorage, which this runtime does not provide; on cloudflare add the nodejs_compat compatibility flag
// server/utils/user.ts
import { getCookie, useEvent } from "silgi/server";

export async function currentUser() {
  const session = getCookie("sid");
  return session ? { session, path: useEvent().url.pathname } : null;
}

useLogger, Logger, LogLevel, LogFields, LogMethod, LogRecord, LogSink

Without an event, the server logger; with one, event.log, the server logger bound with the request id as reqId. There is no lookup of the current request: pass the event, or use event.log. A loader has it as ctx.event.log. The level is server.logLevel (info by default) and the format server.logFormat (pretty in silgi dev, JSON lines in a build); SILGI_LOG_LEVEL and SILGI_LOG_FORMAT override them at run time. Values under keys such as password, token, secret or authorization are written as [redacted].

function useLogger(event?: SilgiEvent): Logger
type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"
type LogFields = Record<string, unknown>
interface LogMethod {
  (msg: string, fields?: LogFields): void;
  (error: unknown, msg?: string, fields?: LogFields): void;
}
interface Logger {
  readonly level: LogLevel | "silent";
  trace: LogMethod; debug: LogMethod; info: LogMethod; warn: LogMethod; error: LogMethod; fatal: LogMethod;
  child(bindings: LogFields): Logger;
  enabled(level: LogLevel): boolean;
}
interface LogRecord { time: number; level: LogLevel; msg: string; bindings: LogFields; fields?: LogFields; err?: unknown }
interface LogSink { write(record: LogRecord): void; flush?(): Promise<void> }

getRequestId

The id of the request, made on first read and then fixed. It is the server.requestIdHeader value (x-request-id by default) when server.trustProxy is on, the platform's own header on cloudflare (cf-ray), vercel (x-vercel-id) and netlify (x-nf-request-id), else crypto.randomUUID(); a header value must be 1 to 200 characters of A-Z a-z 0-9 . _ : @ / + = -. Once an id exists, the answer carries it in the server.requestIdHeader header, unless the route set that header. An in-process $fetch from the request gives the route it calls the same id.

function getRequestId(event: SilgiEvent): string

withSpan, NOOP_SPAN, Span, ReadonlySpan, SpanExporter, SpanKind, SpanAttributes, SpanAttributeValue

With server.tracing on (SILGI_TRACING=1 turns it on at run time), each sampled request has a request span, and Silgi adds child spans for server and route middleware, the route handler, loaders, the render, $fetch calls, storage operations and cached functions. withSpan runs fn in one more child span of the request; a throw is recorded on it as an exception event with status error and rethrown. It finds the request through options.event, else through the async context the bun, node, vercel, netlify and cloudflare servers provide. With tracing off, or a request that is not sampled, fn gets the frozen NOOP_SPAN and no span is made.

The incoming W3C traceparent is adopted with its sampled flag (unless server.tracing.trustIncoming is false); a new trace is sampled when Math.random() < server.tracing.sample. $fetch sends traceparent on in-process calls and, on the network, when server.tracing.propagate is true (the default) or lists the target's origin; a traceparent the caller sets wins. A sampled page carries <meta name="traceparent">, except a prerendered page and one a route rule caches. The request span ends when the handler has the answer's headers ready (on the node preset, when the response has been sent); with server.tracing.bodyTiming: true a streamed body Silgi built (streaming SSR, a returned ReadableStream) is piped so the span ends when the stream is done. That pipe costs throughput, so it is off by default; fixed bodies are never piped.

function withSpan<T>(name: string, fn: (span: Span) => T | Promise<T>, options?: { event?: SilgiEvent; kind?: SpanKind; attributes?: SpanAttributes }): Promise<T>
const NOOP_SPAN: Span
type SpanKind = "server" | "client" | "internal"
type SpanAttributeValue = string | number | boolean | string[] | number[] | boolean[]
type SpanAttributes = Record<string, SpanAttributeValue>
interface Span {
  readonly traceId: string;        // 32 lower-case hex
  readonly spanId: string;         // 16 lower-case hex
  readonly sampled: boolean;
  setAttribute(key: string, value: SpanAttributeValue): void;
  setAttributes(attributes: SpanAttributes): void;
  addEvent(name: string, attributes?: SpanAttributes): void;
  recordException(error: unknown): void;
  setStatus(code: "ok" | "error", message?: string): void;
  end(): void;
}
interface ReadonlySpan {
  traceId: string; spanId: string; parentSpanId?: string;
  name: string; kind: SpanKind;
  startTime: number; endTime: number;      // ms since the epoch, fractional
  attributes: SpanAttributes;
  events: Array<{ name: string; time: number; attributes?: SpanAttributes }>;
  status: { code: "unset" | "ok" | "error"; message?: string };
}
interface SpanExporter {
  export(spans: ReadonlySpan[], resource: SpanAttributes): Promise<void>;
  shutdown?(): Promise<void>;
}

Ended spans wait in a queue of at most 2048 (a full queue drops new spans with one warning a minute) and go to every exporter in batches of 512, every 5 seconds on bun and node, when the server drains, and on cloudflare, vercel and netlify through waitUntil once the request span has ended. An exporter that rejects loses that batch, with one warning a minute; the request never sees it.

The OTLP exporter

With tracing on and an endpoint known, Silgi sends the spans itself to an OpenTelemetry collector or any OTLP/HTTP backend, in the OTLP JSON encoding (no protobuf, no gRPC, no dependency). The endpoint is server.tracing.otlp.endpoint as written, else OTEL_EXPORTER_OTLP_TRACES_ENDPOINT as written, else OTEL_EXPORTER_OTLP_ENDPOINT with /v1/traces added. server.tracing.otlp: false turns it off.

export default {
  server: {
    tracing: {
      serviceName: "shop",
      otlp: { endpoint: "https://otel.example.com/v1/traces", headers: { authorization: "Bearer token" }, timeout: 10000 },
    },
  },
};

Headers come from otlp.headers, else OTEL_EXPORTER_OTLP_TRACES_HEADERS, else OTEL_EXPORTER_OTLP_HEADERS (a=1,b=x%20y); the timeout from otlp.timeout, else OTEL_EXPORTER_OTLP_TIMEOUT, else 10000 ms. OTEL_EXPORTER_OTLP_PROTOCOL set to anything but http/json logs one warning and JSON is sent anyway. A flush goes out in requests of at most 512 spans. A network error, 429, 502, 503 or 504 is retried once after Retry-After seconds (at most 5) or 1 second; a timeout and every other status drop the batch with the warning above. The exporter uses globalThis.fetch, so its request has no traceparent and makes no span.

The resource has service.name (serviceName, else OTEL_SERVICE_NAME, else the name in the app's package.json, else silgi-app), service.version (the version in package.json), telemetry.sdk.name, telemetry.sdk.language and telemetry.sdk.version, plus the pairs of OTEL_RESOURCE_ATTRIBUTES (deployment.environment.name=prod). On Cloudflare the OTEL_* names are read from the Worker's variables.

setServerTiming

With server.serverTiming on (the default in silgi dev, off in a build; SILGI_SERVER_TIMING=1 or 0 overrides it at run time), every answer except a 101 carries a Server-Timing header that the browser's devtools show: mw (server middleware), handler (the server route), loader (the page's loaders, desc the route names), render (to the rendered shell), cache;desc="hit" or "stale" when a route-rule or defineCachedHandler cache answered, and total, in ms with one decimal. A Server-Timing the route set comes first. The header tells anyone how long each phase took, which is why a build keeps it off.

setServerTiming adds an entry of your own. name must be 1 to 32 characters of A-Z a-z 0-9 _ - (else a TypeError), description is quoted with " and \ escaped, and a call is dropped once the answer holds 20 entries (Silgi's own entries are never dropped). With the feature off the call does nothing.

function setServerTiming(event: SilgiEvent, name: string, duration: number, description?: string): void

const start = performance.now();
const users = await db.query("select * from users");
setServerTiming(event, "db", performance.now() - start, "users");   // db;dur=3.1;desc="users"

useMetrics, Metrics, MetricAttributes, MetricsSnapshot, MetricExporter

With server.metrics on, the bun and node servers and silgi dev count every request in the histogram http.server.request.duration (seconds, by http.request.method, http.route (the route pattern, left out when nothing matched) and http.response.status_code) and keep http.server.active_requests. On cloudflare, vercel, netlify and static the build warns that server.metrics is ignored, since each request may run in another instance, and the bundle holds no metrics code. useMetrics() gives your own counters, histograms and gauges; with metrics off its instruments do nothing.

function useMetrics(): Metrics
type MetricAttributes = Record<string, string | number | boolean>
interface Metrics {
  counter(name: string, options?: { description?: string; unit?: string }): { add(value: number, attributes?: MetricAttributes): void };
  histogram(name: string, options?: { description?: string; unit?: string; buckets?: number[] }): { record(value: number, attributes?: MetricAttributes): void };
  gauge(name: string, options?: { description?: string; unit?: string }): { set(value: number, attributes?: MetricAttributes): void };
}
interface MetricsSnapshot {
  start: number; time: number;                   // ms since the epoch; cumulative since start
  metrics: Array<{
    name: string; description: string; unit: string; type: "counter" | "gauge" | "histogram";
    points: Array<{ attributes: MetricAttributes; value?: number; sum?: number; count?: number; bounds?: number[]; buckets?: number[] }>;
  }>;
}
interface MetricExporter { export(snapshot: MetricsSnapshot): Promise<void>; shutdown?(): Promise<void> }

const orders = useMetrics().counter("shop.orders", { description: "Orders placed." });
orders.add(1, { plan: "pro" });

A name must start with a letter and hold at most 255 of A-Z a-z 0-9 _ . / - (else a TypeError); asking twice for one name returns the same instrument, and asking for it as another type is a TypeError. Keep attribute values to a small set: past 2000 sets per instrument, new ones are counted in one point with otel.metric.overflow: true. A histogram's buckets default to those of the request histogram, 0.005 to 10; buckets[i] in a snapshot counts the values above the previous bound up to bounds[i], the last one those above every bound.

An exporter added with addMetricExporter gets the snapshot every server.metrics.interval ms (60000 by default) and once more when the server drains.

server.metrics.prometheus serves the metrics to Prometheus at <app.baseURL>_silgi/metrics (prometheus.path changes the part after the base URL) in the text format 0.0.4: http_server_request_duration_seconds_bucket{…,le="0.005"}, _sum, _count, _total on counters. A scrape needs authorization: Bearer <token>, the token from prometheus.token or else SILGI_METRICS_TOKEN; a missing or wrong token answers 401, a method other than GET or HEAD 405. With no token at all the path answers 404 and the server logs one warning at start; token: false makes the endpoint public. A scrape is not counted, logged or traced.

export default {
  server: { metrics: { interval: 30000, prometheus: true } },   // SILGI_METRICS_TOKEN=… in the environment
};

ClientReport, ClientErrorReport, ClientVitalReport, ClientNavigationReport

With server.clientReports on, the browser sends the errors of the page, the web vitals of the document load and one report per client navigation to <app.baseURL>_silgi/reports (path changes the part after the base URL), and the server logger writes each with name: "client". Off by default: a build without it has no route there (a POST is a 404) and the client bundle holds none of the code.

type ClientReport = ClientErrorReport | ClientVitalReport | ClientNavigationReport
interface ClientErrorReport {
  type: "error";
  message: string;                  // at most 1000 characters
  stack?: string;                   // at most 4000
  source: "render" | "navigation" | "plugin" | "hook" | "chunk" | "window" | "rejection" | "console";
  status?: number;                  // of a SilgiError
  route: string; path: string;      // route name, URL path without query or hash
  time: number;                     // the client clock, ms since the epoch
  buildId?: string;
  traceparent?: string;             // the document's, so the error joins the page's trace
}
interface ClientVitalReport {
  type: "vital";
  name: "LCP" | "INP" | "CLS" | "FCP" | "TTFB";
  value: number;                    // ms, CLS without a unit
  rating: "good" | "needs-improvement" | "poor";
  route: string; path: string; navigationType: string;
}
interface ClientNavigationReport {
  type: "navigation";
  from: string; to: string;         // pathnames, without query or hash
  route: string;                    // the target's route name
  duration: number;                 // ms from the click or navigate() call to the page on screen
  dataMs: number;                   // the time the page's load data took, 0 when none was loaded
  chunkMs: number; renderMs: number;
  time: number;
  buildId?: string;
}
export default {
  server: { clientReports: { vitals: 0.1, maxPerPage: 10, navigations: true } },   // true is { errors: true, vitals: true, navigations: true }
};

errors, vitals and navigations switch each kind (vitals as a number is the share of page loads that report them), maxPerPage caps the distinct errors one page sends and, counted apart, its navigation reports (10 each by default; the same message and first stack line count once). A navigation report is queued when a client navigation is on screen and sent with the vitals when the page is hidden. Errors go 1 s after the last one or when the page is hidden, vitals once when it is first hidden, through navigator.sendBeacon, else fetch with keepalive. The vitals come from Silgi's own PerformanceObserver code, with web.dev's thresholds for the ratings.

The endpoint takes POST only, a body of { "reports": [...] } as text/plain or application/json (a sent Sec-Fetch-Site must be same-origin, else 403; another content type 415; above 16384 bytes 413). One to 20 reports, every one valid, else 400 and nothing is logged or passed on; unknown fields are dropped and strings cut. At most 100 reports per second per instance are taken, the rest are dropped and counted at debug. Control characters in the text become spaces before it reaches a log line. An error is logged at error with its fields, a vital at info as LCP 1834ms good /about, a navigation at info as navigation /a → /b 412ms. With server.metrics on, a vital is recorded in the histogram silgi.web_vitals (attributes vital, route, rating; LCP, INP, FCP and TTFB in seconds, CLS as is; a route name the app does not have is recorded as unknown) and a navigation in silgi.client_navigation (ms, attribute route); with tracing on, an error report with a traceparent becomes an ended span client error in that trace, and the handler calls of client navigations carry the page's traceparent, so their server spans join the page's trace. The endpoint is not logged, counted or traced as a request.

Request

getQuery, getValidatedQuery

getQuery returns the query string as an object; a repeated key is an array. getValidatedQuery validates it with validate, a Standard Schema or a function. A schema gives its output value, or a 400 Validation failed with data.issues. A function returns the value, or true or undefined to keep the query, or false for a 400; a SilgiError it throws answers as it is, and a plain TypeError or RangeError (not a subclass) becomes a 400 with its message (what a hand-written check throws). Any other thrown value, such as a database error, becomes a 400 Validation failed outside dev and is logged at warn with the original as err; in dev its message is kept.

function getQuery(event: SilgiEvent): Record<string, string | string[]>
function getValidatedQuery<T>(event: SilgiEvent, validate: StandardSchemaV1<unknown, T>): Promise<T>
function getValidatedQuery(event: SilgiEvent, validate: (value: unknown) => boolean | undefined | void | Promise<boolean | undefined | void>): Promise<unknown>
function getValidatedQuery<T>(event: SilgiEvent, validate: (value: unknown) => T | Promise<T>): Promise<T>

getRouterParam, getRouterParams

The route params of event.params: one by name, or all.

function getRouterParam(event: SilgiEvent, name: string): string | string[] | undefined
function getRouterParams(event: SilgiEvent): Record<string, string | string[]>

getRequestHeader, getRequestHeaders

One request header, or undefined; or all of them by lower-case name, with repeated values joined by , .

function getRequestHeader(event: SilgiEvent, name: string): string | undefined
function getRequestHeaders(event: SilgiEvent): Record<string, string>

getHeader (deprecated)

The same function as getRequestHeader, kept through 1.x. Auto-imports offer only getRequestHeader.

/** @deprecated Use `getRequestHeader`. */
const getHeader: typeof getRequestHeader

getRequestIP, getRequestURL

getRequestIP is the client address the preset reports (bun: the connection's peer; node: the socket's remote address; cloudflare: cf-connecting-ip; vercel: the first x-real-ip value; netlify: context.ip); with { xForwardedFor: true } the x-forwarded-for entry that trustedHops places from the end wins when present. trustedHops is the number of proxies you control, an integer from 1 to 10 (default 1, the last entry, which is the address your nearest proxy saw); with fewer entries than hops you get the first one, and any other value throws a TypeError. The first entry is chosen by the client when a proxy appends to the header, so it is never read by default. Without the header you get the preset address. Trust the header only behind proxies you control. getRequestURL is event.url; with server.trustProxy it applies x-forwarded-host and x-forwarded-proto.

function getRequestIP(event: SilgiEvent, options?: { xForwardedFor?: boolean; trustedHops?: number }): string | undefined
function getRequestURL(event: SilgiEvent): URL
import { defineHandler, getQuery, getRequestIP, getRequestURL, getRouterParam, getRouterParams, getValidatedQuery } from "silgi/server";

export const getItem = defineHandler({
  route: "/api/items/:id",
  method: "GET",
  raw: true,
  async handler(_, { event }) {
    const page = await getValidatedQuery(event, (value) => Number((value as { page?: string }).page ?? 1));
    return {
      query: getQuery(event),
      page,
      id: getRouterParam(event, "id"),
      params: getRouterParams(event),
      ip: getRequestIP(event, { xForwardedFor: true }),
      host: getRequestURL(event).host,
    };
  },
});

useGlobalCss

useGlobalCss(event) returns the URLs of the app's global CSS as a new array, in the order the page document links them, each once, so a server route that renders its own HTML document can link the same stylesheet. In a build it is manifest.css: the CSS imported by app plugins, route middleware and the error page, with app.baseURL and app.buildAssetsDir in each URL; a page's, layout's or app root's CSS is not in it. In silgi dev it is the href of each stylesheet link of the dev client tags, Bun's /_bun/asset/<hash>.css, which holds the CSS of every page. For an event the app handler did not create it returns []. It is also auto-imported.

function useGlobalCss(event: SilgiEvent): string[]
import { defineHandler, useGlobalCss } from "silgi/server";

export const admin = defineHandler({
  route: "/admin",
  method: "GET",
  raw: true,
  handler(_, { event }) {
    const links = useGlobalCss(event).map((href) => `<link rel="stylesheet" href="${href}">`).join("");
    return new Response(`<!DOCTYPE html><html><head><meta charset="utf-8">${links}</head><body>Admin</body></html>`, { headers: { "content-type": "text/html" } });
  },
});

readBody, readFormData, readBodyBytes, readValidatedBody, BodyOptions

The body is read once per request and cached, so the functions can be mixed. A body over maxBodySize (default: server.maxBodySize, 1 MiB) fails with 413. readBody parses JSON, URL-encoded forms and multipart forms by the content-type and returns undefined for an empty body. readValidatedBody validates that value as getValidatedQuery does.

function readBody<T = unknown>(event: SilgiEvent, options?: BodyOptions): Promise<T | undefined>
function readFormData(event: SilgiEvent, options?: BodyOptions): Promise<FormData>
function readBodyBytes(event: SilgiEvent, options?: BodyOptions): Promise<Uint8Array | undefined>
function readValidatedBody<T>(event: SilgiEvent, validate: StandardSchemaV1<unknown, T>, options?: BodyOptions): Promise<T>
function readValidatedBody(event: SilgiEvent, validate: (value: unknown) => boolean | undefined | void | Promise<boolean | undefined | void>, options?: BodyOptions): Promise<unknown>
function readValidatedBody<T>(event: SilgiEvent, validate: (value: unknown) => T | Promise<T>, options?: BodyOptions): Promise<T>
interface BodyOptions { maxBodySize?: number }
import { defineHandler, readBody, readFormData, readBodyBytes } from "silgi/server";
import type { BodyOptions } from "silgi/server";

const limit: BodyOptions = { maxBodySize: 64 * 1024 };

export const upload = defineHandler({
  route: "/api/upload",
  method: "POST",
  raw: true,
  async handler(_, { event }) {
    const type = event.request.headers.get("content-type") ?? "";
    if (type.startsWith("multipart/form-data")) return { fields: [...(await readFormData(event, limit)).keys()] };
    if (type.startsWith("application/json")) return { json: await readBody<{ name?: string }>(event, limit) };
    return { bytes: (await readBodyBytes(event, limit))?.byteLength ?? 0 };
  },
});
import { defineHandler, readValidatedBody, setResponseStatus } from "silgi/server";

export const createPost = defineHandler({
  route: "/api/posts",
  method: "POST",
  raw: true,
  async handler(_, { event }) {
    const input = await readValidatedBody(event, (value) => {
      const { title } = value as { title?: unknown };
      if (typeof title !== "string" || !title) throw new Error("title is required");
      return { title };
    });
    setResponseStatus(event, 201);
    return { id: crypto.randomUUID(), title: input.title };
  },
});

readValidatedForm

Reads the body with readFormData, decodes it with formToObject of silgi/app (a.b and a[0] nest, a repeated name gives an array, n: and b: coerce, "" is undefined) and validates it with a Standard Schema; issues answer 400 Validation failed with data: { issues }, as readValidatedBody does.

function readValidatedForm<T>(event: SilgiEvent, schema: StandardSchemaV1<unknown, T>, options?: BodyOptions): Promise<T>

readMultipart

Reads a multipart/form-data body with Silgi's streaming parser, the one an action's formData() uses, under maxBodySize, maxFileSize (default maxBodySize) and maxFiles (default 10); onFile gets each file part as a stream (see FormDataOptions in silgi/app). A content-length above maxBodySize fails with 413 before a byte is read; a streamed body, a file or a file count above its limit, or more than 1000 text fields, fails with 413 as soon as it is reached; a malformed body (boundary, part headers above 16 KiB, a name above 1024 characters, no closing boundary) fails with 400 Invalid multipart body. A URL-encoded body is read whole as readFormData does; another type fails with 415. The form is parsed once per request.

function readMultipart(event: SilgiEvent, options?: FormDataOptions): Promise<FormData>

readRawBody (deprecated)

The same function as readBodyBytes, kept through 1.x. Auto-imports offer only readBodyBytes.

/** @deprecated Use `readBodyBytes`. */
const readRawBody: typeof readBodyBytes

Response

setHeader, setHeaders, appendHeader

Set response headers on event.response; they are merged into whatever the handler returns. A value with CR, LF or NUL throws.

function setHeader(event: SilgiEvent, name: string, value: string | number): void
function setHeaders(event: SilgiEvent, headers: Record<string, string | number>): void
function appendHeader(event: SilgiEvent, name: string, value: string | number): void
import { defineHandler, setHeader } from "silgi/server";

export const robots = defineHandler({
  route: "/robots.txt",
  method: "GET",
  raw: true,
  handler(_, { event }) {
    setHeader(event, "content-type", "text/plain; charset=utf-8");
    return "User-agent: *\nAllow: /\n";
  },
});

setResponseStatus

Sets the status of the response, 200 to 599, and optionally the status text.

function setResponseStatus(event: SilgiEvent, code: number, text?: string): void

setStatus (deprecated)

The same function as setResponseStatus, kept through 1.x. Auto-imports offer only setResponseStatus.

/** @deprecated Use `setResponseStatus`. */
const setStatus: typeof setResponseStatus

sendRedirect

Returns a redirect Response; return it from the handler. options is the status, 300 to 399 with 302 as the default, or { status?, external? }. An absolute http: or https: URL on the request's own origin is sent as it is. A target on another origin, //host and \\host included, needs external: true; without it the call answers a 500 and sends no Location. Any other scheme throws a TypeError. external: true sends the target as given, so validate a ?next= value yourself before you pass it on.

function sendRedirect(event: SilgiEvent, to: string, options?: number | { status?: number; external?: boolean }): Response
import { appendHeader, defineHandler, sendRedirect, setHeader, setHeaders, setResponseStatus } from "silgi/server";

export const queueJob = defineHandler({
  route: "/api/jobs",
  method: "POST",
  raw: true,
  handler(_, { event }) {
    setHeader(event, "cache-control", "no-store");
    setHeaders(event, { "x-a": 1, "x-b": "two" });
    appendHeader(event, "vary", "accept");
    if (event.url.searchParams.has("old")) return sendRedirect(event, "/new", 301);
    if (event.url.searchParams.has("docs")) return sendRedirect(event, "https://docs.example.com/", { external: true });
    setResponseStatus(event, 202, "Accepted");
    return { queued: true };
  },
});

getCookie, setCookie, deleteCookie, getSignedCookie, setChunkedCookie, getChunkedCookie, deleteChunkedCookie, CookieOptions, SetCookieOptions

getCookie reads a request cookie. setCookie adds a Set-Cookie header to the response; it replaces an earlier one for the same name, Domain (case and a leading dot ignored) and Path (default /) and keeps the others, so deleting sid at /admin and setting it at / sends both. deleteCookie expires one; it takes the same options except maxAge and expires.

CookieOptionsType
domainstring
pathstringdefault /
expiresDate
maxAgenumberseconds
httpOnly, secure, partitionedboolean
sameSite"lax" | "strict" | "none"
function getCookie(event: SilgiEvent, name: string): string | undefined
function setCookie(event: SilgiEvent, name: string, value: string, options?: CookieOptions): void
function deleteCookie(event: SilgiEvent, name: string, options?: Omit<CookieOptions, "maxAge" | "expires">): void

// without the event: the request running now, through useEvent()
function getCookie(name: string): string | undefined
function setCookie(name: string, value: string, options?: CookieOptions): void
function deleteCookie(name: string, options?: Omit<CookieOptions, "maxAge" | "expires">): void
import { defineHandler, deleteCookie, getCookie, setCookie } from "silgi/server";
import type { CookieOptions } from "silgi/server";

const options: CookieOptions = { httpOnly: true, sameSite: "lax", maxAge: 60 * 60 };

export const visits = defineHandler({
  route: "/api/visits",
  method: "GET",
  raw: true,
  handler(_, { event }) {
    const visits = Number(getCookie(event, "visits") ?? 0) + 1;
    if (visits > 10) deleteCookie(event, "visits");
    else setCookie(event, "visits", String(visits), options);
    return { visits };
  },
});

Signed cookies. setCookie(event, name, value, { signed }) writes <value>.<MAC>, where the MAC is the base64url HMAC-SHA-256 (43 characters) of <name>=<value>; the name is inside the MAC, so a value copied to another cookie does not verify. signed: true signs with the app key SILGI_KEY; a string or a list uses those secrets (each at least 32 characters, at most 8): the first signs, every one verifies, which is how a secret is rotated. The HMAC key is derived from the secret with its own HKDF info, so a key made for seal, a session or csrf never verifies a signed cookie. setCookie stays synchronous and returns void: the write is recorded, and the app handler signs it just before the answer is sent, so no await is needed. A later write to the same cookie (a deleteCookie, a plain setCookie) replaces it, as for any setCookie; a signing failure answers as the request's error, never as an unhandled rejection. A bad secret or a prefix error throws at the call. getSignedCookie returns the value, or undefined when the cookie is missing, has no MAC, or its MAC fails under every secret; it throws only for a bad secret list, or for the app key when the server has none and the cookie looks signed. A signed cookie is tamper-proof, not secret: the value is readable. Use seal for a value the browser must not read.

SetCookieOptionsType
everything in CookieOptions
signedtrue | string | string[]true: the app key; else those secrets
function setCookie(event: SilgiEvent, name: string, value: string, options?: SetCookieOptions): void
function getSignedCookie(event: SilgiEvent, name: string, secret?: true | string | string[]): Promise<string | undefined>
import { defineHandler, getSignedCookie, setCookie } from "silgi/server";

export const login = defineHandler({
  route: "/api/login",
  method: "POST",
  raw: true,
  handler(_, { event }) {
    setCookie(event, "__Host-sid", "ada", { signed: true, secure: true, httpOnly: true, sameSite: "lax" });
    return { ok: true };
  },
});

export const me = defineHandler({
  route: "/api/me",
  method: "GET",
  raw: true,
  async handler(_, { event }) {
    return { user: (await getSignedCookie(event, "__Host-sid")) ?? null };
  },
});

Chunked cookies. A value whose name=value passes 4096 bytes does not fit one cookie. setChunkedCookie writes it as name=chunks-2, name.0 and name.1, each cookie at most 4096 bytes, with the attributes of options on all of them; a smaller value is one plain cookie. The name=value bytes of all the cookies together are at most 6144, so a value has at most 2 chunks, else it throws TypeError: cookie "<name>" would need more than 6144 bytes; keep the data in a session store and writes nothing. The limit keeps the cookie line of a request under the 8 KiB that nginx accepts by default, with 2 KiB left for the app's other cookies; keep more data in a session store. getChunkedCookie joins the chunks and returns undefined when the cookie is missing, a chunk is missing, or a chunk beyond the marker's count exists; a cookie that is not a marker is returned as it is. Writing a smaller value deletes the chunks it no longer needs (those of the request, and those set earlier in this response), and deleteChunkedCookie deletes the cookie and all its chunks. Cookie sessions (useSession) use these functions when their seal passes 4096 bytes. Signed cookies and chunks do not combine.

function setChunkedCookie(event: SilgiEvent, name: string, value: string, options?: CookieOptions): void
function getChunkedCookie(event: SilgiEvent, name: string): string | undefined
function deleteChunkedCookie(event: SilgiEvent, name: string, options?: Omit<CookieOptions, "maxAge" | "expires">): void
import { defineHandler, deleteChunkedCookie, getChunkedCookie, setChunkedCookie } from "silgi/server";

export const save = defineHandler({
  route: "/api/draft",
  method: "POST",
  raw: true,
  handler(_, { event }) {
    setChunkedCookie(event, "draft", JSON.stringify({ title: "x".repeat(5000) }), { httpOnly: true, secure: true, sameSite: "lax" });
    return { ok: true };
  },
});

export const read = defineHandler({
  route: "/api/draft",
  method: "GET",
  raw: true,
  handler(_, { event }) {
    return { draft: getChunkedCookie(event, "draft") ?? null };
  },
});

export const discard = defineHandler({
  route: "/api/draft",
  method: "DELETE",
  raw: true,
  handler(_, { event }) {
    deleteChunkedCookie(event, "draft");
    return { ok: true };
  },
});

Prefixes. A name that starts with __Host- or __Secure- (matched without regard to case, as browsers do) is checked by name, since the browser enforces the same rules and drops a cookie that breaks them. __Host- needs secure: true, path / (or none) and no domain; __Secure- needs secure: true. Otherwise setCookie, deleteCookie and useCookie throw TypeError: cookie "__Host-sid" needs secure, path "/" and no domain or TypeError: cookie "__Secure-x" needs secure, with the name as given. The rule reads the options, not the request, so it is the same under silgi dev on http://localhost; browsers other than Safari accept a Secure cookie from http://localhost.

createEventStream, EventStream, EventStreamOptions, EventPushOptions

Server-sent events. push(data, { id, event, retry }?) sends one event (a string as is, anything else as JSON) and writes the id:, event: and retry: lines before the data: lines; the second argument is what keeps push({ id: 1 }) a data object. It returns a promise that resolves when the body's queue has room again, so await push(...) slows a fast producer to its reader; not awaiting it buffers as before. An id or event with CR, LF or NUL, or a retry that is not a non-negative integer of milliseconds, throws a TypeError. After the stream ended push writes nothing. close() ends the stream, and send() returns the Response to return from the handler. Closing the connection closes the stream. onClosed(callback) calls callback once when the stream ends by close(), a closed connection or an aborted request, or on the next microtask when the stream has already ended. heartbeat is the interval in milliseconds of the : ping comment written while the stream is open (default 15000; false or 0 off); a ping is skipped while the body's queue is full.

function createEventStream(event: SilgiEvent, options?: EventStreamOptions): EventStream
interface EventStreamOptions { heartbeat?: number | false }
interface EventPushOptions { id?: string; event?: string; retry?: number }
interface EventStream {
  push(data: unknown, options?: EventPushOptions): Promise<void>
  close(): void
  onClosed(callback: () => void): void
  send(): Response
}
import { createEventStream, defineHandler } from "silgi/server";

export const count = defineHandler({
  route: "/api/count",
  method: "GET",
  raw: true,
  handler(_, { event }) {
    const stream = createEventStream(event);
    let count = 0;
    const timer = setInterval(() => {
      void stream.push({ count: ++count }, { id: String(count) });
      if (count === 5) {
        clearInterval(timer);
        stream.close();
      }
    }, 1000);
    stream.onClosed(() => clearInterval(timer));
    return stream.send();
  },
});
import { createEventStream, defineHandler } from "silgi/server";
import type { EventStream } from "silgi/server";

export const events = defineHandler({
  route: "/api/events",
  method: "GET",
  raw: true,
  handler(_, { event }) {
    const stream: EventStream = createEventStream(event);
    void stream.push("ready", { retry: 3000 });
    void stream.push({ done: true });
    stream.close();
    return stream.send();
  },
});

WebSocketHooks, WebSocketOptions, WebSocketPeer, WebSocketData

A WebSocket is the websocket key of defineHandler: defineHandler({ route: "/_ws/chat", websocket: { open, message, close, error, origin } }), a GET route that upgrades the request, with no handler, no client stub and no _silgi/fn endpoint, whose middleware and handlerMiddleware run before the upgrade. Only the bun preset serves it; building for any other preset fails with an error that names the route. A request that is not an upgrade gets 426. websocket takes the hooks and origin, with the values of WebSocketOptions.origin and the default "same"; any other key throws websocket key "x" is not a key; use origin, open, message, close, error. The key needs a route (websocket needs a route) and refuses input, output, events, raw and status (websocket cannot be used with input, output, events, raw or status: a socket has hooks, not an answer). The hooks are open(peer), message(peer, message), close(peer, code, reason) and error(peer, error). Text frames arrive as strings and binary frames as Uint8Array. A WebSocketPeer has id, request, params, context, send(data) and close(code?, reason?). WebSocketData is string | ArrayBuffer | Uint8Array.

The handshake is refused with 403 and the message WebSocket origin not allowed before open runs when its Origin is not allowed. WebSocketOptions.origin is "same" (default: no Origin, the request's host, or an origin of server.originCheck.allow), "any", a list of origins (each its own URL.origin, else a TypeError; an absent Origin also passes) or a function (origin, event) that returns whether to upgrade; origin is null when the header is absent.

import { defineHandler } from "silgi/server";

export const socket = defineHandler({
  route: "/ws",
  websocket: {
    open(peer) {
      peer.send("welcome");
    },
    message(peer, message) {
      peer.send(`echo: ${message}`);
    },
  },
});
import { defineHandler } from "silgi/server";
import type { WebSocketData, WebSocketHooks, WebSocketOptions, WebSocketPeer } from "silgi/server";

const send = (peer: WebSocketPeer, data: WebSocketData) => peer.send(data);

const hooks: WebSocketHooks = {
  open: (peer) => send(peer, `hello ${peer.id}`),
  message: (peer, message) => send(peer, message),
  close: (peer, code, reason) => console.log(peer.id, code, reason),
  error: (peer, error) => console.error(peer.id, error),
};

const options: WebSocketOptions = { origin: ["https://app.example"] };

export const chat = defineHandler({ route: "/ws", websocket: { ...hooks, ...options } });

GraphQL

defineResolvers, defineScalar, defineGraphQLContext, GraphQLResolvers, GraphQLScalar, GraphQLContextFactory

Used in server/graphql/*.resolver.ts and server/graphql/context.ts when server.graphql is on. The GraphQL guide covers the files, the config and the endpoint. defineResolvers maps type names to field resolvers (an object type), { __resolveType } (an interface or union) or a defineScalar result (a custom scalar). A field of the subscription root is { subscribe, resolve? }: subscribe returns an AsyncIterable of events, and resolve(event, args, context, info), when given, maps each event; without it the event's field of the same name is read. A field without a resolver reads parent[fieldName]. A field defined in two files, a key that is not in the schema and a root field without a resolver (a subscription field without subscribe) fail the first GraphQL request, naming the files.

function defineResolvers(resolvers: GraphQLResolvers): GraphQLResolvers
function defineScalar<Internal, External>(scalar: {
  serialize(value: Internal): External;
  parseValue(value: unknown): Internal;
  parseLiteral?(node: ConstValueNode): Internal;
}): GraphQLScalar<Internal, External>
function defineGraphQLContext<T extends object>(factory: (event: SilgiEvent) => T | Promise<T>): GraphQLContextFactory<T>

The context of a resolver is { event } merged with the result of the factory, which runs once per request after server middleware.

FieldResolver, ResolveType, GraphQLResolveInfo, GraphQLContext

type FieldResolver<Parent, Args, Result> = (parent: Parent, args: Args, context: GraphQLContext & { event: SilgiEvent }, info: GraphQLResolveInfo) => Result | Promise<Result>
type ResolveType = (value: any, context: GraphQLContext & { event: SilgiEvent }, info: GraphQLResolveInfo) => string | Promise<string>
GraphQLResolveInfoType
fieldNamestring
coordinatestringschema coordinate, "Post.author"
pathReadonlyArray<string | number>response path, list indexes included
parentType, returnTypestringreturnType is written like "[Post!]!"
operation{ kind: "query" | "mutation" | "subscription"; name?: string }
variablesReadonly<Record<string, unknown>>coerced variable values
directivesReadonlyArray<{ name: string; args: Record<string, unknown> }>custom directives applied to the field in the schema
signalAbortSignalaborts with the request; no further resolver is called after it

GraphQLContext is an empty interface; the context a resolver gets is { event } merged with the result of the factory. Typegen makes it extend the return type of server/graphql/context.ts.

GraphQLSchemaTypes, GraphQLFields, GraphQLModels, GraphQLModel, GraphQLScalars, GraphQLScalarInternal, GraphQLScalarExternal

Typegen writes .silgi/types/graphql.d.ts from the schema while server.graphql is on: GraphQLSchemaTypes holds the shape of every object, interface, union and input type, GraphQLFields the parent, args and result of every field, so defineResolvers types each resolver without annotations. With no generated file, GraphQLResolvers accepts any type name.

declare module "silgi/server" {
  interface GraphQLModels { Post: PostRow }                          // the value Post resolvers pass around
  interface GraphQLScalars { DateTime: { internal: Date; external: string } }
}
type GraphQLModel<N extends string>          // GraphQLModels[N], else GraphQLSchemaTypes[N]; an interface or union: its possible types' models
type GraphQLScalarInternal<N extends string> // GraphQLScalars[N]["internal"], else unknown
type GraphQLScalarExternal<N extends string> // GraphQLScalars[N]["external"], else unknown

GraphQLError

class GraphQLError extends Error {
  constructor(message: string, options?: { extensions?: Record<string, unknown>; cause?: unknown })
  extensions: Record<string, unknown>
}

Throw it from a resolver to answer with that message and extensions. A thrown SilgiError keeps its message and answers with extensions: { status, data? }. Any other thrown value becomes Internal server error with extensions.code: "INTERNAL_SERVER_ERROR" when server.graphql.maskErrors is on (the default in a build) and is passed to the error server hook. The HTTP status stays 200: it is a field error.

Errors and fetching

createError, isSilgiError, SilgiError, CreateErrorOptions

The same functions as in silgi/app. In a handler, a thrown SilgiError answers with its status, its statusText as the status line text, and a JSON body { status, statusText?, message, data? } (statusText only when it is not the default text of the status), or the error page for a browser that asks for HTML; these negotiated answers carry Vary: Accept. Any other thrown value with a numeric status (else statusCode) from 400 to 599, such as the FetchError of a $fetch that got a 404, answers with that status, the status text in production and its own message in dev, and no data; every other value answers 500. A response with status 101, 204, 205 or 304, and every answer to HEAD, has no body.

import { SilgiError, createError, defineHandler, isSilgiError } from "silgi/server";
import type { CreateErrorOptions } from "silgi/server";

const options: CreateErrorOptions = { status: 404, message: "No such project", data: { id: 7 } };

export const getProject = defineHandler({
  route: "/api/projects/:id",
  method: "GET",
  raw: true,
  handler() {
    try {
      throw createError(options);
    } catch (error) {
      if (isSilgiError(error) && error instanceof SilgiError) return { status: error.status, data: error.data };
      throw error;
    }
  },
});

FetchData, RouteInput, SilgiFetchRoutes

The same types as in silgi/app: FetchData<"/api/posts/1"> is the type $fetch("/api/posts/1") resolves to, RouteInput<"/api/posts", "POST"> the body it takes, both unknown for a URL no route matches.

$fetch, createFetch, FetchError, $Fetch, FetchOptions, FetchResult, ResponseType, CreateFetchOptions, AppFetch, ForwardedCookies, FetchContext, FetchHook

The same client as in silgi/app, here always the in-process one: $fetch("/api/posts") calls the app without a socket. createFetch({ local, event, baseURL }) makes a client of your own; event makes it forward the request's cookie and authorization headers, and baseURL is put before a URL starting with / when a call passes no baseURL of its own (the app's app.baseURL for the $fetch of a running app). CreateFetchOptions is the type of that argument, AppFetch is (request: Request) => Promise<Response> (what local returns) and ForwardedCookies is the cookies option: { forward(lines: string[], url: string, method: string): void; header(): string | null }.

import { $fetch, FetchError } from "silgi/server";

export async function loadPost(slug: string) {
  try {
    return await $fetch<{ title: string }>(`/api/posts/${slug}`, { query: { draft: false } });
  } catch (error) {
    if (error instanceof FetchError) console.error(error.status, error.data);
    throw error;
  }
}

export function createPost(title: string) {
  return $fetch<{ id: string }>("/api/posts", { method: "POST", body: { title }, timeout: 5000 });
}

Sessions and security

useSession, SessionOptions, Session, SessionData

A session kept in an encrypted cookie. A tampered or expired cookie reads as an empty session. Calling it twice in one request with the same cookie name returns the same session; other options for that name throw.

function useSession<T extends SessionData = SessionData, F extends Record<string, unknown> = Record<string, unknown>>(event: SilgiEvent, options?: SessionOptions): Promise<Session<T, F>>
SessionOptionsType
passwordstring | string[]at least 32 characters; a list of 1 to 8 rotates: the first seals, every entry opens; default the app key SILGI_KEY (see seal)
namestringcookie name, default silgi-session
maxAgenumberseconds, at least 1
cookieCookieOptions without maxAge and expiresdefaults path: "/", httpOnly, secure, sameSite: "lax"
storagestringa storage mount, the session store: the cookie holds only the sealed id and the data lives under <name>:<id> with a ttl of what is left of maxAge or idleTimeout, one of which is then required. The mount session defaults to fs at .data/sessions on bun, node, under silgi dev and in the prerender, to cloudflare-kv binding SESSION on cloudflare when the wrangler config declares it, and to nothing on vercel and netlify (set storage.session)
idleTimeoutnumberseconds, a whole number of at least 1: a session not used for that long reads as empty. maxAge counts from creation, idleTimeout from the last use, and whichever ends first wins
headerstringa request header name: the sealed value is read from that header and written to the same response header, and no cookie is read, set or deleted (for clients without cookies)

A Session<T, F> has id, data, update(patch), which merges and writes the cookie, regenerate({ data }), which gives it a new id and a full maxAge, keeps the data unless data replaces it, writes the cookie and invalidates CSRF tokens bound to the old id, and clear(), which empties it, gives it a new id and deletes the cookie. rotated is true when the cookie opened with a password other than the first of the list; the next update seals it with the first. SessionData is Record<string, unknown>. Call regenerate() on every login and privilege change, never trust keys written before the login (regenerate({ data: {} }) starts clean), and never key your own records by the id from before it. A request still carrying the old cookie can only write the old id with the old data back, never the login. A write after maxAge has run out deletes the cookie, as clear() does.

flash(key, value) keeps a value apart from data for a later request, and consumeFlash(key) returns it once: the request that consumes it removes it in its write, so the next request gets undefined. A flash set before a redirect is on the next page; F types the keys and values (Session<Data, { notice: string }>), and session.data never shows one. consumeFlash returns a value flashed earlier in the same request, returns undefined and writes nothing when the key has none, and a flash of the same key replaces the earlier one. The value goes through the payload serializer in a cookie as in a store, so Date, Map, Set and BigInt come back as they went in; the value is copied at the call, so later changes to your object change nothing; one the serializer refuses throws at the flash call with its path (TypeError: Cannot serialize a function at flash.notice), and undefined throws TypeError: session.flash needs a value; undefined cannot be stored. A key that is not a string throws TypeError: session.flash key must be a string (session.consumeFlash key must be a string). In a cookie session the flash counts toward the 4096 bytes; a storage session writes it with the data. regenerate() keeps the flash values, clear() drops them, and a refresh with idleTimeout neither consumes nor drops one. Concurrent requests: a cookie session cannot tell, so two requests that carry the same cookie both receive the flash, and the response that lands last decides what the client keeps: a request that updated or only refreshed (idleTimeout) carries the flash it loaded, so it can bring back a flash another request consumed and drop one another request set. A storage session applies the flashes and consumes of a request to the entry as it re-reads it just before the write, key by key, so a write never brings back a flash another request already consumed and never drops one another request set (a consume removes the key only while the entry still holds the value the request read); the store has no compare-and-set, so two requests that both read the entry before either writes both receive the flash.

With idleTimeout, a read after more than half of it has passed since the session was last used seals it again, so a client that uses the seal it is given refreshes at most once per half interval. A cookie session sets the cookie again with the data that request loaded (a stateless cookie cannot do otherwise, and no cookie session can be revoked: use storage for that). A storage session only re-issues the seal and writes nothing to the store, because the key's ttl is what is left of maxAge; without maxAge the refresh renews the ttl of the entry as it is stored. An idle-expired seal reads as empty and is dropped; its entry is left to its ttl. With header, the response carries the new seal in that header whenever a cookie would be set, with an empty value when the client should drop it (clear(), an idle-expired seal), and never a Set-Cookie. The name is lower-cased and must not be cookie, set-cookie, authorization, proxy-authorization, host, transfer-encoding, connection, keep-alive, upgrade, te, trailer, location, vary, cache-control, or begin with content- or access-control-; a header seal does not open as a cookie seal. An answer that carries a session header is Cache-Control: private, no-store and is never stored by a cache; one that read a header session varies on that header. The header is a bearer token, not an ambient credential: the origin check still judges the request as for any other, a non-browser client that sends no Origin passes it, and csrf() is not needed for the header. A route that answers other origins must expose the header with Access-Control-Expose-Headers, which you set yourself until cors.exposeHeaders exists.

With storage, the store is read once, at the first useSession of a request, and a request that only reads writes nothing. Every update, regenerate and clear of a request lands in one write, made before the response is sent, so a login that redirects is stored when the next page asks; a write after that throws. A failed write answers as an error without the session cookie. regenerate() and clear() mark the old key as ended, and removing <name>:<id> from the mount logs that session out; a request still running on the ended session then writes nothing and deletes its cookie. Concurrent requests of one live session: the last write wins. On cloudflare, KV takes one write per second per key, and for about 60 seconds after a logout a request at another location can still read the session and write it back; use a db or redis mount where revocation must hold. The default fs store keeps its files private (0600) and removes expired ones.

import { createError, defineHandler, useSession } from "silgi/server";

const password = process.env.SESSION_PASSWORD as string;

const nameInput = {
  "~standard": {
    version: 1 as const,
    vendor: "docs",
    validate: (value: unknown) => {
      const name = (value as { name?: unknown } | undefined)?.name;
      return typeof name === "string" && name ? { value: { name } } : { issues: [{ message: "name is required", path: ["name"] }] };
    },
  },
};

export const login = defineHandler({
  route: "/api/login",
  method: "POST",
  input: nameInput,
  async handler({ name }: { name: string }, { event }) {
    if (name.length > 64) throw createError({ status: 400, message: "name is too long" });
    const session = await useSession(event, { password, maxAge: 60 * 60 * 24 * 7 });
    await session.regenerate();
    await session.update({ user: name });
    return { id: session.id };
  },
});
import { createError, defineHandler, useSession } from "silgi/server";

const password = process.env.SESSION_PASSWORD as string;

export const me = defineHandler({
  route: "/api/me",
  method: "GET",
  async handler(_, { event }) {
    const session = await useSession<{ user?: string }>(event, { password });
    if (!session.data.user) throw createError({ status: 401, message: "Not signed in" });
    return { user: session.data.user };
  },
});
import { defineHandler, useSession } from "silgi/server";
import type { Session, SessionData, SessionOptions } from "silgi/server";

const options: SessionOptions = { password: process.env.SESSION_PASSWORD as string, name: "shop", maxAge: 3600, cookie: { sameSite: "strict" } };

interface Cart extends SessionData {
  items?: string[];
}

type Notices = { notice: string };

export const addToCart = defineHandler({
  route: "/api/cart",
  method: "POST",
  raw: true,
  async handler(_, { event }) {
    const session: Session<Cart, Notices> = await useSession<Cart, Notices>(event, options);
    await session.update({ items: [...(session.data.items ?? []), "book"] });
    await session.flash("notice", "Added to your cart");
    return session.data;
  },
});

csrf, CsrfOptions

Signed double-submit CSRF protection. A safe method (GET, HEAD, OPTIONS, TRACE, QUERY) gets a token to embed in the page, and sets the cookie. A cross-origin QUERY is allowed only when CORS methods lists it. Any other method must send the same valid token in the x-csrf-token header or the _csrf form field, else the call throws a 403. Pass session to bind the token to a session.

function csrf(event: SilgiEvent, options?: CsrfOptions): Promise<string>
interface CsrfOptions { secret?: string; maxBodySize?: number; secure?: boolean; session?: Pick<Session, "id"> }

secret is at least 32 characters; without it the tokens are signed with a key derived from the app key SILGI_KEY (see seal), and every key of a rotated list verifies them. secure defaults to whether the request is https:; when it is true the cookie is __Host-silgi-csrf.

import { csrf, defineHandler, readBody, useSession } from "silgi/server";

const password = process.env.SESSION_PASSWORD as string;
const secret = process.env.CSRF_SECRET as string;

export const account = defineHandler({
  route: "/account",
  method: "*",
  raw: true,
  async handler(_, { event }) {
    const session = await useSession(event, { password });
    const token = await csrf(event, { secret, session });
    if (event.method === "GET") {
      return `<form method="post"><input type="hidden" name="_csrf" value="${token}"><button>Save</button></form>`;
    }
    const form = await readBody<Record<string, string>>(event);
    return { saved: Object.keys(form ?? {}).filter((key) => key !== "_csrf") };
  },
});
import { csrf, defineHandler } from "silgi/server";
import type { CsrfOptions } from "silgi/server";

const options: CsrfOptions = { secret: process.env.CSRF_SECRET as string, secure: true };

export const csrfToken = defineHandler({
  route: "/api/csrf",
  method: "GET",
  raw: true,
  async handler(_, { event }) {
    return { token: await csrf(event, options) };
  },
});

hashPassword, verifyPassword, needsRehash

Password hashes in one format on every preset: hashPassword writes $pbkdf2-sha256$i=600000$<salt>$<hash> (PBKDF2-HMAC-SHA-256, a 16-byte random salt, a 32-byte key, PHC B64), so a hash made on one preset verifies on any other.

function hashPassword(password: string): Promise<string>
function verifyPassword(hash: string, password: string): Promise<boolean>
function needsRehash(hash: string): boolean

The password is hashed as the UTF-8 bytes of its NFC form, so two spellings of one accented letter match. An empty password or one over 4096 UTF-8 bytes makes hashPassword throw a TypeError; verifyPassword returns false for both. The parameters come from config, server.passwordHash: { algorithm?: "pbkdf2-sha256" | "argon2id", iterations?: number }: iterations is an integer from 600000 to 10000000 (default 600000), and argon2id (through Bun.password) builds on the bun preset only. On cloudflare, where Workers refuse more than 100000 PBKDF2 iterations in Web Crypto, Silgi computes the same function in JavaScript, which costs CPU time on every login.

verifyPassword reads $pbkdf2-sha256$ strings, $argon2id$ strings on bun (elsewhere it throws argon2id hashes verify only on the bun preset), and the $scrypt$ strings nuxt-auth-utils writes, so a Nuxt app's users can log in after the move. A string it cannot read returns false and logs one warning naming only its id. The comparison is constant-time. On static both functions throw: there is no server.

needsRehash is true for a $scrypt$ string, for another algorithm than the configured one, for fewer iterations than configured and for an unreadable string; never for more iterations. After a successful login, hash the password again when it is true and store the new string.

seal, unseal

Encrypts a value into a URL-safe string and opens it again, for a value that leaves the server and must come back unchanged and unread: an invite link, a reset token, a value in a hidden field.

function seal(value: unknown, options?: { password?: string | string[]; purpose?: string; ttl?: number }): Promise<string>
function unseal<T = unknown>(sealed: string, options?: { password?: string | string[]; purpose?: string }): Promise<T | undefined>

The string is 2.<salt>.<iv>.<ciphertext> in base64url, AES-GCM-256 under a key derived from the password, with purpose (default "silgi-seal") bound to it: unseal with another purpose gives undefined, and so does a session cookie, which uses another key. The value goes through the payload serializer, so Date, Map, Set and BigInt come back as they went in; a value it refuses throws its error naming the path (value.user.save). ttl is a positive integer of seconds after which unseal gives undefined. A value whose sealed form would be over 1 MiB throws TypeError: seal value is too large: sealed it would be over 1 MiB, which unseal refuses.

password defaults to the app key SILGI_KEY. A given password follows useSession's rules: at least 32 characters, a list of at most 8, the first seals and every entry opens. A purpose starting with silgi- is Silgi's own and throws TypeError: seal purpose "silgi-oauth" is reserved for Silgi. unseal gives undefined for anything malformed, tampered with, expired, over 1 MiB or sealed under another password or purpose; it never throws for its input.

SILGI_KEY is one key, 32 bytes in base64 or base64url, or a comma-separated list of at most 8 (SILGI_KEY=<new>,<old> rotates: the first seals, every entry opens). A server with a malformed SILGI_KEY does not start (SILGI_KEY must be 32 bytes of base64, SILGI_KEY holds at most 8 keys). Without it, silgi dev makes one key when it starts, keeps it across server rebuilds and warns once; a built server and a production prerender throw SILGI_KEY is not set; run silgi create-key --env, or set it in the deployment at the first call that needs the key. On cloudflare the key is read from the Worker's env at the first request.

signJWT, verifyJWT, JWTError, JWTAlgorithm, JWTKey, JWTPayload

Signs and verifies JSON Web Tokens (compact JWS) on Web Crypto, the same on every preset, and verifies against a key list or a JWKS URL.

type JWTAlgorithm = "HS256" | "HS384" | "HS512" | "RS256" | "RS384" | "RS512" | "PS256" | "PS384" | "PS512"
  | "ES256" | "ES384" | "Ed25519" | "EdDSA";
type JWTKey = CryptoKey | JsonWebKey | string | Uint8Array;
interface JWTPayload { iss?: string; sub?: string; aud?: string | string[]; exp?: number; nbf?: number; iat?: number; jti?: string; [claim: string]: unknown }

function signJWT(payload: JWTPayload, key: JWTKey, options: {
  alg: JWTAlgorithm; expiresIn?: number; notBefore?: number; issuer?: string; audience?: string | string[];
  subject?: string; kid?: string; jti?: boolean | string; typ?: string;
}): Promise<string>
function verifyJWT<T extends JWTPayload = JWTPayload>(token: string,
  key: JWTKey | JWTKey[] | { jwksUri: string; cacheTtl?: number },
  options: { algorithms: JWTAlgorithm[]; issuer?: string | string[]; audience?: string | string[]; subject?: string;
    clockTolerance?: number; maxAge?: number; requiredClaims?: string[]; typ?: string },
): Promise<T>
class JWTError extends Error { code: "malformed" | "algorithm" | "key" | "signature" | "expired" | "not_before"
  | "issuer" | "audience" | "subject" | "claim" | "type" }

signJWT writes the header { alg, typ: "JWT", kid } and iat = now; expiresIn and notBefore are seconds from now, jti: true writes 16 random bytes. A claim given both in the payload and as an option throws TypeError: signJWT got exp in the payload and expiresIn.

verifyJWT accepts only the algorithms in algorithms, which is required; none is never accepted or produced. A string or bytes key is an HMAC secret and nothing else, at least as long as the hash; a string that starts with -----BEGIN is refused, so a PEM public key can never become an HMAC secret. A key binds its algorithm: an RSA key never verifies an HS256 token, and a JWK with alg, use or key_ops that do not allow the header's algorithm is refused. RSA keys are at least 2048 bits with the public exponent 65537. A key too small for its algorithm throws a TypeError from signJWT (JWT HS256 needs a secret of at least 32 bytes) and fails verification with key in verifyJWT, where the token picks the algorithm. ES256 and ES384 signatures are the raw r || s of RFC 7518; a DER signature does not verify. A header with crit or b64 is refused, and jku, x5u, jwk and x5c are ignored. With a key list, the key whose kid is the header's is used, else the keys without a kid.

Then come exp, nbf (both with clockTolerance seconds, default 0), maxAge against iat, issuer, audience (a token without aud fails when audience is given, and a token with aud fails when it is not), subject, requiredClaims and the header typ (case-insensitive, an application/ prefix ignored). Every failure throws a JWTError whose message is JWT <code>: <detail>.

With { jwksUri } the set is fetched over https (or http on localhost, 127.0.0.1, [::1]; else TypeError: jwksUri must be an https URL), within 5 s and 64 KB, without following a redirect, and kept in the cache storage mount for cacheTtl seconds (default 600). A token with a kid the set does not have fetches the set again at most once per URL per 60 s, then fails with key. A failed fetch is also retried at most once per 60 s, and the last set fetched keeps serving for up to an hour past cacheTtl while the URL fails. Symmetric (oct) keys in a set are ignored.

requireJWT

The verified claims of the request's authorization: Bearer <token> header, else of the cookie named by cookie.

function requireJWT<T extends JWTPayload = JWTPayload>(event: SilgiEvent,
  options: Parameters<typeof verifyJWT>[2] & { key: Parameters<typeof verifyJWT>[1]; cookie?: string }): Promise<T>

Without a token it throws a 401 Unauthorized with WWW-Authenticate: Bearer; with a token that fails, a 401 with WWW-Authenticate: Bearer error="invalid_token". The JWTError code is logged at debug and never sent.

requireBasicAuth, basicAuth, BasicAuthOptions, Secret

HTTP Basic authentication (RFC 7617). requireBasicAuth checks the request's authorization header, sets event.context.basicAuth = { username } and returns it, or throws a 401. basicAuth is the same check as a request step for defineServer({ middleware }), handlerMiddleware and defineMiddleware({ server: basicAuth(…) }) on a handler.

function requireBasicAuth(event: SilgiEvent, options: BasicAuthOptions): Promise<{ username: string }>
function basicAuth(options: BasicAuthOptions): ServerStep
type BasicAuthOptions = (
  | { username: string; password: Secret }
  | { verify: (username: string, password: string, event: SilgiEvent) => boolean | Promise<boolean> }
) & { realm?: string }
type Secret = string | { runtimeConfig: string }

A missing, malformed, non-UTF-8 or wrong header throws a 401 Unauthorized with WWW-Authenticate: Basic realm="<realm>", charset="UTF-8"; realm defaults to "Silgi". The header is Basic <base64> (the scheme in any case) holding UTF-8 text split at the first :, so a password may hold colons; text with a control character is malformed, and so is base64 that is not canonical (wrong padding, stray trailing bits). With { username, password } both fields are always compared, as SHA-256 digests through a constant-time comparison, so neither the length nor the first wrong field leaks; NFC and NFD of one text are the same credentials. { runtimeConfig: "<key>" } reads the password from the runtime config at the call, and a key that is unset or empty throws an Error, which is a 500, never a pass. verify decides when it returns true, and anything else is a 401; it is not called for a header that could not be read.

Options that cannot protect anything throw a TypeError where the check or the step is made: a realm that is not a string or holds ", \, a control character or a character above U+00FF (requireBasicAuth realm must not contain ", \\, a control character or a character above U+00FF), an empty username or password, a username with :, neither or both of { username, password } and verify.

requireBearerToken, BearerTokenOptions

Checks the request's bearer token (RFC 6750) against a secret, a list of them, or a function, and returns the token.

function requireBearerToken(event: SilgiEvent, options: BearerTokenOptions): Promise<string>
type BearerTokenOptions =
  | { token: Secret | Secret[] }
  | { verify(token: string, event: SilgiEvent): boolean | Promise<boolean> }

A missing authorization header, or one with another scheme, throws a 401 Unauthorized with WWW-Authenticate: Bearer; a token that does not match, or that verify does not accept, a 401 with WWW-Authenticate: Bearer error="invalid_token"; a Bearer header whose token is not [A-Za-z0-9\-._~+/]+=* (empty, or holding a space or a comma) a 400 Bad Request with WWW-Authenticate: Bearer error="invalid_request". The scheme is case-insensitive. A token list is compared entry by entry through SHA-256 digests and a constant-time comparison, every entry and without an early exit. token and verify together, an empty token or an empty list throw a TypeError; an unset { runtimeConfig } key throws an Error.

handleCors, CorsOptions

CORS for one request. A preflight returns a 204 Response to return from the handler; any other request gets its CORS headers on event.response and undefined. A wrong option throws when it is called.

function handleCors(event: SilgiEvent, options?: CorsOptions): Response | undefined
CorsOptionsType
origin"*" | string | string[] | ((origin: string) => boolean)default "*", which cannot be combined with credentials
methodsstring[]default GET, HEAD, PUT, PATCH, POST, DELETE
headersstring[]default: the requested headers
credentialsboolean
maxAgenumberseconds a preflight may be cached
import { defineMiddleware, handleCors } from "silgi/server";

export const cors = defineMiddleware(({ event }) =>
  handleCors(event, {
    origin: ["https://app.example.com"],
    methods: ["GET", "POST"],
    credentials: true,
    maxAge: 600,
  }),
);
import { defineMiddleware, handleCors } from "silgi/server";
import type { CorsOptions } from "silgi/server";

const options: CorsOptions = { origin: (origin) => origin.endsWith(".example.com"), headers: ["content-type"] };

export const cors = defineMiddleware(({ event }) => handleCors(event, options));

createNonce

A fresh CSP nonce: 16 random bytes from crypto.getRandomValues, as 24 base64 characters. Set it as event.context.nonce in server middleware and put it in your own Content-Security-Policy header; the document then writes it on every script and modulepreload tag it emits and in <meta property="csp-nonce">. A value of event.context.nonce that is not base64 of at least 22 characters is ignored with one warning.

function createNonce(): string

A page with a nonce answers cache-control: no-store and cannot be prerendered.

Tasks

defineTask, Task, TaskContext

A task is a file under server/tasks/ (or a layer's) whose default export is defineTask({ … }). Its name is the path below server/tasks/ without the extension, / turned into :: server/tasks/db/seed.ts is db:seed; index is not special. Files and folders whose name starts with _ or ., *.d.ts, *.test.* and *.spec.* are skipped; each name segment holds A-Z, a-z, 0-9, _ and -. The build reads the file as text: description is a string literal, and the options are an object literal written out, not spread.

function defineTask<P = undefined, R = unknown, In = P>(task: {
  description?: string;
  input?: StandardSchemaV1<In, P>;
  run(payload: P, ctx: TaskContext): R | Promise<R>;
}): Task<In, Awaited<R>>
interface TaskContext {
  name: string;
  scheduledTime?: number;                       // on a scheduled run only
  cron?: string;                                // on a scheduled run only
  signal: AbortSignal;                          // aborts when the bun or node server drains
  waitUntil(promise: Promise<unknown>): void;   // the calling request's; else the process's, which the drain awaits
  log: Logger;                                  // the server logger's child with { task: name }
}

input validates the payload before run, as a handler's input does: a refusal rejects with 400 Validation failed and data.issues, and run does not run. A caller passes the schema's input type (In); run gets its output. Another key fails at definition: defineTask has no "meta"; it takes description, input, run.

runTask, TaskName, TaskPayload, TaskResult, TaskPayloadArgs, TaskTypes, SilgiTasks

Runs a task and resolves to what its run returned. It awaits the server's plugins first, so a plugin cannot call it: there it rejects at once (runTask("x") was called in a server plugin; …). The payload is copied at the call. A call while a run of the same name and the same serialized payload is pending joins that run and gets the same result value; a run that failed is never joined again, and a run never joins itself. An unknown name rejects with 404 Task "x" not found; a throw in run rejects with that value.

function runTask<N extends TaskName>(name: N, ...payload: TaskPayloadArgs<N>): Promise<TaskResult<N>>

run executes outside the request: useEvent() inside it throws useEvent() was called in task "db:seed", which runs outside a request, and a handler called in process throws TypeError: posts.ts#getPost was called in task "report", which runs outside a request; move the shared code into a function both import. Put code both need in a plain function in a server module. With tracing on, each run is a task <name> span, a child of the caller's.

Typegen fills SilgiTasks (.silgi/types/tasks.d.ts) with each task's TaskTypes, { payload; result }. TaskName is its keys, or string while it is empty; TaskPayload<N> and TaskResult<N> read it (unknown while empty), and TaskPayloadArgs<N> makes the payload optional when the task accepts undefined. So runTask("db:sed") and a payload of the wrong type fail tsc.

enqueue

Hands a run of a task to a queue and resolves when the queue took it, not when the task ran; the result is not returned. payload is always passed, so a task without one is enqueued as enqueue("report", undefined, { delay: 3600 }). delay is in whole seconds from 0 to 43200 (default 0), else enqueue delay must be a whole number of seconds from 0 to 43200 (got 50000). An unknown name rejects with 404 Task "x" not found, and the payload is written with the payload serializer at the call, so a value it cannot write rejects there (Cannot serialize a function at payload.fn).

function enqueue<N extends TaskName>(name: N, payload: TaskPayload<N>, options?: { delay?: number }): Promise<void>
PresetWhere the run goesdelay
bun, node, silgi devthis process, through the request's waitUntil (outside a request, the process's list); a hot rebuild of silgi dev keeps the old server until its runs settleda timer
cloudflare with tasks.queueenv[binding].send({ task, payload }, { contentType: "json", delaySeconds }), payload the serializer's text. The messages are only produced: the worker consumes them once C-668 landsyes
vercel with tasks.queueVercel Queues: POST https://<VERCEL_REGION>.vercel-queue.com/api/v3/topic/<name> with the deployment's OIDC token and the { task, payload } JSON; the private function _silgi/vercel/queue.func runs each message's task, acknowledges it when the task resolved and leaves it for redelivery when it threw. A refused message rejects with Vercel Queues refused the message to topic "jobs": <status> <body>yes, Vqs-Delay-Seconds
cloudflare without it, vercel without it, netlifythis process, through the platform's waitUntil of the request or of the task run that called itrefused: enqueue delay needs a queue on the netlify preset: a function stops after its answer (; set tasks.queue on cloudflare and vercel)
static, and the prerender of any presetthis process; the prerender awaits the runs before it endsrefused: enqueue delay is refused during the prerender: the build ends before the run would start

Each call is a run of its own: in process it never joins another enqueue or a pending runTask of the same payload. Called from a server plugin, the run starts once the plugins have finished. In process, enqueue is not durable: a restart or a crash loses the runs still waiting, and the drain drops them with one warn line, 3 enqueued task runs were dropped at shutdown (enqueue is not durable on the node preset); an enqueue after the drain began rejects and logs enqueue("x") was refused: the server is shutting down (enqueue is not durable on the node preset). A failing run is logged and reaches task:error with trigger: "enqueue"; there is no retry. On cloudflare a message over 128 KB rejects before anything is sent (enqueue payload is 140 KB; Cloudflare Queues take 128 KB): store a large payload and enqueue its key.

outsideWorker

For a module's Workers driver: the rejection its binding lookup gives when the server runs on Bun (silgi dev, silgi task run --prod of a cloudflare app). The message is <subject>, which exists only in the deployed worker, and inside a task it adds ; run the task there through tasks.http (POST <baseURL>_silgi/tasks/<name>) naming that task. Storage mounts and the @silgi/blob cloudflare-r2 driver use it; a driver that keeps its lookup should drop a failed one, so the next use names its own task.

function outsideWorker(subject: string): Promise<never>
// outsideWorker('blob uses the cloudflare-r2 binding "BLOB"')

Storage and cache

useStorage, Storage, SetItemOptions, StorageDriver, StorageMeta, StorageEvent

useStorage(name) returns a mount of the storage config; a mount the config does not name is in memory. Values are serialized, so Date, Map and the other payload types survive. Keys are normalized, and : separates levels.

function useStorage<S extends object = any>(name: string): Storage<S, "">
interface SetItemOptions { ttl?: number }
interface StorageMeta { mtime?: Date; size?: number; expires?: number; [custom: string]: unknown }
Storage method
getItem<T>(key), setItem(key, value, options?)read and write a value; ttl is in seconds
hasItem(key), removeItem(key)
getKeys(prefix?, { maxDepth? }), clear(prefix?)keys at or below a prefix; maxDepth counts the segments below it, so a:1 and a:b:2 under a with maxDepth: 1 give a:1 (a key equal to the prefix is depth 0); anything but a non-negative integer is a TypeError
getItems(keys), setItems(items)getItems gives { key, value } in the given order, value undefined for a missing key; setItems takes { key, value, options? } and a value of undefined removes the key. Items keep ttl and <key>$ rules of setItem; a refused key throws before anything is written. One MGET on redis, one SELECT and one transaction on sqlite, the single calls together elsewhere
getItemRaw(key), setItemRaw(key, bytes, options?)Uint8Array without serialization
getMeta(key), setMeta(key, meta), removeMeta(key)what the backend knows (mtime and size on memory, fs, sqlite and s3; expires in ms where an expiry is stored, redis included) merged with the custom fields of setMeta; a missing key gives {}
increment(key, by?, options?)adds the integer by (default 1) and gives the new value; a missing key starts at 0, and ttl applies only when the call creates the key
watch(callback)calls callback(event, key) with event "update" or "remove" after every change to an item, and gives unwatch; see below

Custom metadata is stored under <key>$ with the item's expiry, so it ends with the item: getKeys never lists it, removeItem and clear remove it with the item, a write carries its new expiry over to it, and setMeta of a missing item throws TypeError: storage "x": setMeta of "k" needs an existing item. A key whose last segment ends in $ is refused: TypeError: Storage key "a$" ends in "$", which marks metadata. increment is atomic on memory, sqlite and redis; on other drivers it runs under a per-key lock of this process and logs once per mount increment on <driver> is not atomic across instances. A stored value that is not an integer fails: TypeError: storage "x": increment of "k" needs an integer value (got an object). by and the result must be safe integers: RangeError: increment needs a safe integer (got 18446744073709552000), RangeError: storage "x": increment of "k" by 1 leaves the safe integer range (the value is 9007199254740991), which stores nothing.

watch(callback) reports setItem, setItemRaw, setItems, increment, setMeta and removeMeta (as "update" of the item), removeItem and clear (as "remove", once per key) made through this process, on every driver; keys are relative to the storage, and to the view for prefixStorage. A write is reported once its call has succeeded, and a callback's throw is logged at error without stopping the others. fs also reports files changed by another process, using a recursive fs.watch debounced for 50 ms: a file that appears or changes is "update", one that is gone is "remove", and a change to an item's <key>$ file is "update" of the item. The watcher starts with the first watch and stops with the last unwatch and when the driver is disposed. fs recognises its own writes by the hash of the bytes it wrote and does not report them a second time; a different write to the same file is always reported. An overlay merges the watches of its layers and hides changes that a value or tombstone in the first layer covers. clear reports only the keys that are gone afterwards, so noClear reports nothing. A callback that returns a rejected promise is logged like a throw. If a driver cannot start watching (for example ENOSPC from inotify), watch still works for this process's writes, the error is logged and the next watch tries again. When mountStorage replaces a mount, its listeners move to the new driver. An expired item is not reported when it expires.

type StorageEvent = "update" | "remove"
watch(callback: (event: StorageEvent, key: string) => void): () => void

A StorageDriver is what a driver implements: getItem, setItem, hasItem, removeItem, getKeys and clear, over bytes, and optionally getItems(keys) (values in order, undefined for a missing one) and setItems(items) ({ key, value, expires? }, a value of undefined removing the key; a later item of the same key wins; sqlite runs it as one transaction), getMeta(key) (the backend's StorageMeta, undefined for a missing item) and increment(key, by, expires?) (atomic; undefined, storing nothing, when the stored value or the result is not a safe integer) watch(callback) (reports changes made outside this process's own writes and gives a function that stops it; fs only) and dispose() (releases a socket or a database handle).

import { defineHandler, useStorage } from "silgi/server";

export const user = defineHandler({
  route: "/api/user",
  method: "GET",
  async handler() {
    const kv = useStorage("cache");
    await kv.setItem("user:1", { name: "Ada", joined: new Date() }, { ttl: 60 });
    const user = await kv.getItem<{ name: string; joined: Date }>("user:1");
    const keys = await kv.getKeys("user");
    return { user, keys };
  },
});
import { useStorage } from "silgi/server";
import type { SetItemOptions, Storage, StorageDriver } from "silgi/server";

const options: SetItemOptions = { ttl: 30 };

export async function remember(kv: Storage, key: string) {
  await kv.setItem(key, { seen: true }, options);
  await kv.setItemRaw(`${key}:raw`, new TextEncoder().encode("bytes"));
  return (await kv.hasItem(key)) && (await kv.getItemRaw(`${key}:raw`)) !== undefined;
}

export const driver = (storage: StorageDriver) => storage.getKeys("");
export const session = useStorage("session");

prefixStorage

A view of a storage whose keys are relative to a prefix: prefixStorage(storage, "users") writes users:1 for the key 1. It is a full Storage; getKeys() strips the prefix, clear() clears only below it, through the driver's own clear of users: (an item named like the prefix, users, is not in the view and is kept), and getMeta, increment, ttl, hasItem and the raw methods work on the mapped key. A key cannot leave the view with ... The prefix is normalized like a key and refused when empty or when it ends in $: TypeError: prefixStorage prefix "" is empty after normalization, TypeError: prefixStorage prefix "users$" ends in "$", which marks metadata.

function prefixStorage<S, P extends string, B extends string>(storage: Storage<S, P>, base: B): Storage<S, `${P}${B}:`>
function prefixStorage<S extends object>(storage: Storage, base: string): Storage<S, "">

useStorage<S>(name) and Storage<S> take a schema, an object type from key patterns to value types. getItem, setItem, getItems and setItems are typed by the key: a key that matches a pattern has its value type, a literal key that matches none is a type error (a typo is caught), and a string variable has unknown. A union key must match in every member. getItem<T>(key) and getItems<T>(keys) with an explicit T take any key. setItems checks each item against its own key. Storage without arguments is the untyped storage, and every Storage<S> is assignable to it. prefixStorage keeps the schema (user, user: and user/ are one prefix); a string prefix gives the untyped Storage.

interface Schema { [k: `user:${string}`]: User; count: number }
const kv = useStorage<Schema>("data");
await kv.setItem("user:1", { name: "ada" });
await kv.setItem("user:1", 5);          // error: 5 is not a User
await kv.setItem("usr:1", user);        // error: no pattern matches usr:1
const users = prefixStorage(kv, "user");
const ada = await users.getItem("1");   // User | undefined

snapshot, restoreSnapshot

snapshot(storage, prefix?) reads every item at or below prefix into an object and restoreSnapshot(storage, data, prefix?) writes one back, both in silgi/server. Keys are relative to the prefix (snapshot(storage, "users") gives { "1": ... }) and restoreSnapshot(storage, data, "users") writes users:1; the item named like the prefix is not included. Values are read in one getItems call and written in one setItems call. A value the serializer cannot read is kept as an ArrayBuffer of its bytes and restored with setItemRaw; a Uint8Array is a value that was serialized (setItem(key, bytes)) and is restored with setItems. Expired items are left out; a TTL and metadata (<key>$) are not carried. Restoring adds to the storage and removes nothing; a value of undefined in data removes its key.

function snapshot(storage: Storage, prefix?: string): Promise<Record<string, unknown>>
function restoreSnapshot(storage: Storage, data: Record<string, unknown>, prefix?: string): Promise<void>
const backup = await snapshot(useStorage("data"));
await restoreSnapshot(useStorage("copy"), backup);

mountStorage, unmountStorage, defineStorageDriver

Mount and unmount storage at run time, usually from a server plugin. mountStorage(name, driver) replaces a mount of the same name, and a replaced driver is disposed unless another mount still uses it. unmountStorage(name) removes the mount and awaits its driver's dispose(); after it, useStorage(name) gets a fresh memory mount, as for any name the config does not mount. At drain, after the close hook (even when it throws), every mounted driver is disposed once. redis closes its connection and sqlite its database; a disposed driver rejects every later call.

Calls already running on an unmounted or replaced mount finish first, and the driver is disposed once they settle; the drain waits for those disposals too. A Storage you kept from before refuses new calls: Error: storage "x" was unmounted; call useStorage("x") for the current mount.

function mountStorage(name: string, driver: StorageDriver): Storage
function unmountStorage(name: string): Promise<void>
function defineStorageDriver<F extends (options: never) => StorageDriver>(factory: F): F
import { definePlugin, mountStorage } from "silgi/server";
import { createClient } from "./my-backend.ts";

export default definePlugin(() => {
  mountStorage("x", createClient());
});

storage.<name>.driver in the config can also be a path from the app root ("./server/drivers/mine.ts", ../ or absolute) or a package, whose default export is a factory that takes the mount's other keys. defineStorageDriver types that factory and gives it back as it is, so extra methods of the driver it returns keep their types. The build bundles the driver, a package with the preset's export conditions (workerd on cloudflare, node on node), and does not check it against the preset. A driver that does not resolve fails the build with Cannot find storage driver "./x.ts" for mount "y" (built-in: memory, fs, redis, …), followed by the drivers modules added, if any; one whose default export is not a function stops the server at start with TypeError: Storage mount "y": the driver "./x.ts" has no default export that is a factory (options) => StorageDriver.

// server/drivers/mine.ts
import { defineStorageDriver } from "silgi/server";

export default defineStorageDriver((options: { base?: string }) => ({
  async getItem(key) { /* … */ },
  // setItem, hasItem, removeItem, getKeys, clear, and optionally dispose
}));

createStorageHandler, StorageHandler, StorageHandlerOptions, StorageAccess

Serves a mount over HTTP for the http storage driver of another app. Call it from a raw handler on a catch-all route; the optional catch-all **:key? also serves the bare route, which lists and clears the whole mount.

function createStorageHandler(mount: string, options: StorageHandlerOptions): StorageHandler
type StorageHandler = (event: SilgiEvent, key?: string | readonly string[]) => Promise<Response>
interface StorageHandlerOptions { authorize: (event: SilgiEvent, access: StorageAccess) => unknown }
interface StorageAccess { key: string; type: "read" | "write"; prefix: boolean; meta: boolean }
import { createError, createStorageHandler, defineHandler } from "silgi/server";

const storageHandler = createStorageHandler("shared", {
  async authorize(event, { type }) {
    const user = await requireUser(event);
    if (type === "write" && !user.admin) throw createError({ status: 403 });
  },
});

export const storage = defineHandler({ method: "*", route: "/_storage/**:key?", raw: true, handler: ({ key }, { event }) => storageHandler(event, key) });

authorize is required (TypeError: createStorageHandler needs authorize) and runs before every operation. GET and HEAD are read, PUT and DELETE are write. prefix is true for a listing or a clear, where key is the prefix ("" for the whole mount) and a grant covers every key below it (<prefix>:, not the item named like the prefix), and false for one item, so a rule for the item user can refuse GET /_storage/user/. meta is true for a key Silgi keeps beside an item (its metadata <key>$, an overlay tombstone <key>$$ or <key>$$$): key is then the item's key, so the item's rule guards them too. A thrown SilgiError answers its status and message; any other throw answers 401 without its message. Returning anything other than undefined or true, false included, answers 403.

RequestAnswer
GET <key>200 with the stored bytes, application/octet-stream, and x-silgi-expires: <ms since the epoch> when the item has an expiry; 404 when missing or expired
HEAD <key>200 with content-length and x-silgi-expires, no body; 404
PUT <key>the body is stored as it is, with the expiry in an optional x-silgi-expires; 204
DELETE <key>204
GET <prefix>/, GET of the bare route200 with a JSON array of the keys below <prefix>: (every key for the bare route); the item <prefix> itself is not listed
DELETE <prefix>/clears the keys below <prefix>: through the driver's clear("<prefix>:"), as prefixStorage(...).clear() does, and keeps the item <prefix>; 204
any other method405 with Allow: GET, HEAD, PUT, DELETE (Allow: GET, HEAD, DELETE for a prefix)

Keys travel as the path below the route, one segment per : level, each percent-encoded; a key that ends in / is a prefix. These answer 400 before authorize runs:

RequestMessage
a key normalizeKey refuses (a . or .. segment, NUL)Invalid storage key "a/../b"
an empty item key (/_storage/%3A)A storage key must not be empty
a key over 1024 bytesA storage key must be at most 1024 bytes (got 1025)
a key ending in $ other than <key>$, <key>$$, <key>$$$Invalid storage key "a$$$$": only <key>$, <key>$$ and <key>$$$ may end in "$"
a prefix ending in $Invalid storage prefix "a$": a prefix must not end in "$"
an x-silgi-expires that is not a positive integerx-silgi-expires must be a time in ms since the epoch (got "soon")
a PUT of <key>$ whose body is not a serialized { fields, expires? } record, checked after authorizeInvalid metadata for "secret": the body must be a serialized { fields, expires? } record

A write to a read-only mount (fs with readOnly) answers 403 storage "ro" is read-only. A PUT body is read before the request counts as in flight, so a slow upload does not hold unmountStorage or the drain. authorize sees keys with their case; on a case-insensitive file system (macOS, Windows) the fs driver keeps SECRET and secret in one file, so a rule there must not depend on case. Every GET and HEAD answer carries cache-control: no-store, and a PUT body is held to server.maxBodySize. x-silgi-expires is an absolute time, so the clocks of both apps must agree to within the shortest ttl you use. A listing is one JSON array of every key below the prefix, with no paging, so each getKeys or clear of a large mount walks it whole on both sides.

The handler works on the mount's driver, so metadata keys pass through and the remote Storage behaves as a local one, an overlay mount included; its calls count as calls in flight, so unmountStorage and the drain wait for them before disposing the driver. On a driver without getMeta the expiry is kept in the item's <key>$ as Storage keeps it. The server assets and a github mount are served too, as their file bytes: read them on the other side with getItemRaw, since getItem there reads the stored-value format; a write answers 403 with the driver's message (github driver is read-only). A mount set as a whole Storage with no driver has nothing to serve: TypeError: createStorageHandler: storage "x" has no driver to serve.

signS3Request, S3Credentials

Signs a request to an S3 API (AWS S3, Cloudflare R2, MinIO and others) with AWS Signature Version 4 in the Authorization header and returns a Request ready for fetch. It uses Web Crypto only, so it runs on every preset. The s3 storage driver uses it everywhere except on bun, where it uses Bun.S3Client.

function signS3Request(options: S3Credentials, request: { method: string; key: string; query?: Record<string, string>; headers?: HeadersInit; body?: BodyInit | null }): Promise<Request>
interface S3Credentials { bucket: string; region?: string; endpoint?: string; accessKeyId: string; secretAccessKey: string; sessionToken?: string; virtualHostedStyle?: boolean }
S3Credentials
bucketrequired
regiondefault us-east-1; R2 takes auto
endpointdefault https://s3.<region>.amazonaws.com
accessKeyId, secretAccessKey, sessionTokenthe session token is sent as x-amz-security-token
virtualHostedStylehttps://<bucket>.<endpoint host>/<key> instead of <endpoint>/<bucket>/<key>

key is the object key ("" for the bucket itself, as in a ListObjectsV2 call). Each /-separated part of the key and each query name and value is percent-encoded except A-Z a-z 0-9 - . _ ~, so a key with spaces, + or non-ASCII characters is sent and signed as S3 expects; a key with a . or .. part throws a TypeError, because a URL cannot carry it. Every header you pass is signed. A string, ArrayBuffer, typed array or URLSearchParams body is hashed into x-amz-content-sha256; a stream, Blob or FormData body is sent as UNSIGNED-PAYLOAD. S3 refuses a chunked upload, so a stream body needs a content-length header; without one signS3Request throws TypeError: signS3Request needs a "content-length" header for a stream body (S3 refuses a chunked upload): PUT <key>.

import { signS3Request } from "silgi/server";

const credentials = { bucket: "uploads", endpoint: "https://<account>.r2.cloudflarestorage.com", region: "auto", accessKeyId, secretAccessKey };
const response = await fetch(await signS3Request(credentials, { method: "PUT", key: "notes/a.txt", body: "hello", headers: { "content-type": "text/plain" } }));

presignS3

Returns a presigned URL for one GET or PUT of an S3 object: AWS Signature Version 4 in the query string (X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date, X-Amz-Expires, X-Amz-SignedHeaders, X-Amz-Signature, and X-Amz-Security-Token with a session token), with an UNSIGNED-PAYLOAD body. Whoever holds the URL can make that request until it expires, with no credentials, so a browser can download or upload directly to the bucket. It uses Web Crypto only and runs on every preset, bun included. S3Credentials and the key encoding are those of signS3Request.

function presignS3(options: S3Credentials, request: { key: string; method: "GET" | "PUT"; expiresIn: number; contentType?: string; contentLength?: number; query?: Record<string, string> }): Promise<string>
Option
keythe object key
methodGET to download, PUT to upload
expiresInseconds the URL stays valid, a whole number from 1 to 604800 (S3's limit of seven days); otherwise it throws RangeError: presign expiresIn must be 1 to 604800 seconds (got 0)
contentTypesigned: the PUT must send this Content-Type, or S3 refuses it with 403
contentLengthsigned: the request must send this Content-Length, so S3 refuses a body of another size; without it a presigned PUT cannot limit the size
queryadded to the URL and signed, as { partNumber: "1", uploadId } for a multipart UploadPart URL; an X-Amz-* name in any case throws RangeError: presignS3 query cannot set "X-Amz-Expires": the X-Amz-* parameters are the signature's

host is always signed. A contentLength that is not a whole number of bytes throws RangeError: presignS3 contentLength must be a whole number of bytes (got -1).

import { presignS3 } from "silgi/server";

const url = await presignS3(credentials, { method: "PUT", key: "avatars/1.png", expiresIn: 600, contentType: "image/png" });
// the browser: await fetch(url, { method: "PUT", body: file, headers: { "content-type": "image/png" } })

Server assets and readAsset

Files under server/assets/** of the app and its layers (the app's file winning by path) are the read-only mount useStorage("assets:server"); a module adds assets:<name> with addServerAssets (silgi/kit). Keys are paths with / turned into :: server/assets/data/a.json is data:a.json. getItemRaw gives the bytes, getItem parses a .json file and gives other text files as text, and every write throws TypeError: assets:icons is read-only. A storage mount whose name starts with assets: fails at load.

readAsset(url) reads an imported asset URL as a Response: from disk on bun, node and in the static prerender, through the assets binding on cloudflare, and with a fetch of the URL against the request's origin (useEvent()) on vercel and netlify. A data: URI is decoded. A missing file answers 404. A URL that is neither a path nor a data: URI throws TypeError: readAsset(url): "<url>" is not an asset URL.

function readAsset(url: string): Promise<Response>
import fontUrl from "./fonts/mono.woff2";

export const font = defineHandler({ route: "/font", handler: () => readAsset(fontUrl) });

defineCachedFunction, CacheOptions

Caches the result of a function in storage, by its arguments. While an entry is fresh (maxAge seconds) it is returned; once stale it is returned at once and refreshed in the background (swr, on by default), up to staleMaxAge seconds past maxAge. Concurrent calls with the same key share one run.

The returned function also has invalidate(...args), which removes the entry for those arguments so the next call runs fn.

function defineCachedFunction<Args extends unknown[], R>(fn: (...args: Args) => R | Promise<R>, options: CacheOptions<Args>): ((...args: Args) => Promise<R>) & { invalidate(...args: Args): Promise<void> }
CacheOptionsType
maxAgenumberseconds an entry is fresh; required
swrbooleandefault true
staleMaxAgenumberseconds a stale entry may still be served
getKey(...args) => string | Promise<string>default: the serialized arguments
basestringstorage mount, default cache
import { defineCachedFunction } from "silgi/server";

export const getStars = defineCachedFunction(
  async (repo: string) => {
    const response = await fetch(`https://api.github.com/repos/${repo}`);
    const data = (await response.json()) as { stargazers_count: number };
    return data.stargazers_count;
  },
  { maxAge: 300, getKey: (repo) => repo },
);

defineCachedHandler, CachedHandlerOptions

Caches the response of an event function (GET or HEAD): status, headers and body. Other methods run as they are. It returns an event handler; a raw handler calls its handler(event). A GET handler that wants a cache uses its own cache option instead. The handler runs on a copy of the event whose request carries only the headers varies names (and x-forwarded-host/x-forwarded-proto with server.trustProxy), with no client address, so a cookie or authorization header reaches it only when varies lists it. A response with a Set-Cookie header, a non-2xx status, no-store, no-cache or private in cache-control, a Vary naming a header not in varies, or content-type: text/event-stream is not stored and goes to its request as it is; when a refresh gets such a response, the stored entry is removed. The key is the host, path and query unless getKey is set, plus the values of the varies headers. invalidate(url) removes the entries of an absolute URL, every varies variant included. The copy keeps event.context, so useEvent().context inside a cached handler is the first caller's: a per-visitor value there must not be cached.

function defineCachedHandler<T>(input: HandlerFunction<T> | HandlerObject<T>, options: CachedHandlerOptions): EventHandler<T> & { invalidate(input: Request | string): Promise<void> }
interface CachedHandlerOptions extends CacheOptions<[SilgiEvent]> { varies?: string[] }
import { defineCachedHandler, defineHandler } from "silgi/server";

const prices = defineCachedHandler(
  async () => {
    const response = await fetch("https://example.com/prices.json");
    return response.json();
  },
  { maxAge: 60, staleMaxAge: 600, varies: ["accept-language"] },
);

export const priceList = defineHandler({
  route: "/prices",
  method: "GET",
  raw: true,
  handler: (_, { event }) => prices.handler(event),
});
import { defineCachedFunction } from "silgi/server";
import type { CacheOptions, CachedHandlerOptions } from "silgi/server";

const options: CacheOptions<[string]> = { maxAge: 60, swr: false, base: "cache", getKey: (id) => `user:${id}` };
export const handlerOptions: CachedHandlerOptions = { maxAge: 30, varies: ["accept"] };

export const user = defineCachedFunction(async (id: string) => ({ id }), options);

invalidateRouteCache

Removes what the swr and cache route rules stored for an absolute URL: the page and a GET server route at that path. A URL no caching rule covers is left alone. It acts on the app handler created last, the one $fetch reaches.

function invalidateRouteCache(url: string | URL): Promise<void>

silgi/server/http

contentType, matchesETag, parseRange

The rules public files are served by, for a handler or module that serves bytes of its own (@silgi/blob's serve uses them). They live on this subpath, not silgi/server, so they never become auto-imports of server files; like silgi/server, it is refused in client code.

import { contentType, matchesETag, parseRange } from "silgi/server/http";
function contentType(path: string): string
function matchesETag(ifNoneMatch: string | null, etag: string): boolean
function parseRange(header: string | null, size: number): { start: number; end: number } | "unsatisfiable" | undefined

contentType gives the type of a path by its extension, the table public files use (text/html; charset=utf-8 for .html, image/svg+xml for .svg), and application/octet-stream for an extension it does not know. matchesETag is true when an If-None-Match value lists the tag (weak or strong) or is *. parseRange reads one bytes= range of a body of size bytes as an inclusive { start, end }, clamped to the body; it gives "unsatisfiable" for a range that starts past the end (answer 416 with content-range: bytes */<size>) and undefined when the header is absent, malformed or lists several ranges (answer the whole body).