Server

Server code lives in server/ and runs on every preset. It uses the Web Request and Response; everything is imported from silgi/server. A server is made of handlers, and one file, server/index.ts, says which of them are reachable and what runs around them.

The one door

server/index.ts has two kinds of content. Its named exports are the handlers: what a page can import from #server and what the server answers. Its default export, defineServer, is everything else: plugins, request middleware and handler middleware, in the order written.

import { defineServer } from "silgi/server";
import { removeGuard } from "./guards/remove.ts";
import { requireLogin } from "./guards/login.ts";
import { timing } from "./guards/timing.ts";
import { timer } from "./timer.ts";

export { listUsers } from "./admin.ts";
export { addNote } from "./notes.ts";
export { sendContact } from "./contact.ts";
export { getAccount, getDashboard, listOrders } from "./dashboard.ts";
export { getWeather, serverTime } from "./cached.ts";
export { checksum, report, upload } from "./files.ts";
export { getComments, getPost, listPosts, renamePost } from "./posts.ts";
export { hello } from "./hello.ts";
export { logIn, logOut, whoami } from "./session.ts";
export { countdown } from "./stream.ts";
export { addTodo, listTodos, removeTodo } from "./todos.ts";

export default defineServer({
  plugins: [timer],
  middleware: [removeGuard, { route: "/admin/**", handler: requireLogin }],
  handlerMiddleware: [timing],
});
defineServer keyHoldsRuns
pluginsdefinePlugin(…) resultsonce, when the server starts
middlewaredefineMiddleware(…), or { route, handler } to run under one pathbefore every request that matches, in this order
handlerMiddlewaredefineMiddleware(…)around every handler of this door, before its own middleware

Any other key throws at build: defineServer has no "fetch"; it takes plugins, middleware, handlerMiddleware. The build reads this file without running your code. It holds imports, re-exports and the default export, nothing else; module code would run on one side of the split, so it fails with server/index.ts:<line>: this file is split by the build; it holds imports, re-exports and export default defineServer(…); move other code to a file it imports. Each route is a string literal in :param syntax. handlerMiddleware and middleware must be array literals, and every entry of handlerMiddleware an imported name, because the client stub embeds its client part.

What the file may export, and the errors for the rest:

  • export { a, b as c } from "./x.ts" and export * from "./x/index.ts" are followed to any depth. A handler is const a = defineHandler({ … }) at the top level of its file, with any name, in any folder; server/index.ts itself defines nothing.
  • A constant, a class or a factory call in the chain fails: server/index.ts: export "a" is not defined with defineHandler: the build reads \const a = defineHandler({ … })` at its definition and runs nothing`. A star over a file with helpers therefore fails; name the handlers instead.
  • Two stars that provide one name fail: server/index.ts: export "same" comes from both ./a.ts and ./b.ts; JavaScript would drop it. Name one: export { same } from "./a.ts".
  • A package name fails: a package joins with addServer (see Modules).

Folders under server/ mean nothing: the tree is yours, and moving a file never changes what is public or what guards it. server/utils (auto-imports, see Modules) and server/graphql (GraphQL) keep their rules. An app with no server/index.ts has no server code: pages render, public/ is served, and import { x } from "#server" fails with #server needs server/index.ts: create it and export the handlers the client may call.

A handler with a route

Without route a handler is internal: pages call it, nothing else does, and it is not in the OpenAPI document. With route the same handler also answers a public URL as a JSON endpoint, and a page still calls getPost({ id }).

import { createError, defineHandler, refresh } from "silgi/server";
import { comments, posts } from "./utils/posts.ts";
import { idInput, schema } from "./utils/schema.ts";

export const listPosts = defineHandler({
  method: "GET",
  handler: () => [...posts.values()],
});

export const getPost = defineHandler({
  method: "GET",
  route: "/api/posts/:id",
  input: idInput,
  handler({ id }) {
    const post = posts.get(id);
    if (!post) throw createError({ status: 404, message: "Post not found" });
    return post;
  },
});

export const getComments = defineHandler({
  method: "GET",
  route: "/api/posts/:id/comments",
  input: idInput,
  handler: ({ id }) => comments.get(id) ?? [],
});

const rename = schema<{ id: string; title: string }>(
  { type: "object", properties: { id: { type: "string" }, title: { type: "string", minLength: 1 } }, required: ["id", "title"] },
  (value) => {
    const { id, title } = value as { id?: unknown; title?: unknown };
    if (typeof id !== "string" || typeof title !== "string" || !title) return { issues: [{ message: "A title is required", path: ["title"] }] };
    return { value: { id, title } };
  },
);

export const renamePost = defineHandler({
  input: rename,
  async handler({ id, title }, { event }) {
    const post = posts.get(id);
    if (!post) throw createError({ status: 404, message: "Post not found" });
    posts.set(id, { ...post, title });
    await refresh(event, getPost, { id });
    return { id, title };
  },
});

route is a string literal below app.baseURL: :name, :name?, a mixed segment such as /shop/:category-:id, and a named last catch-all /**:rest. An unnamed /**, a * segment, a missing leading / and a pattern that does not parse fail the build with the file and the export in the message. method is GET, POST (the default), PUT, PATCH, DELETE, HEAD, OPTIONS, CONNECT, TRACE, QUERY or, with raw, "*"; every method but GET and POST needs a route. Two handlers on one path with two methods are one route: a PUT on a path that has GET and DELETE answers 405 with allow: DELETE, GET, HEAD. The same method and path twice fails the build and names both sides:

Server route "/v1/x" (server/p.ts#b) conflicts with "/v1/x" (server/p.ts#a): duplicate GET /v1/x

A server route and a page can share a path. The route wins for every method but GET, and for a GET whose Accept does not include text/html; otherwise the page wins.

Input. The path, the query and the body merge into one object, the input of the call. GET, HEAD, OPTIONS, CONNECT and TRACE read the path and the query; the other methods read the body too. A name that two places send with two values answers 400 Input "id" differs between path and body (path and query, query and body), so PUT /v1/users/1 with {"id": 2} cannot pass a check on the path and write another row. A body that is not an object is the whole input when the route has no parameter and the request no query. Nothing is coerced: a parameter is text until the schema says otherwise, so input with z.number() on a path parameter answers 400 on every call; use z.coerce.number(). The type checker names a route parameter that input lacks, and silgi dev answers 500 with getPost: route "/api/posts/:id" has parameter "id", which input does not declare for a schema it can read.

A request with a body must be application/json; any other type answers 415 with accept-post, accept-patch or accept-query. A route takes no form body. The body limit is server.maxBodySize.

Answer. The value is sent as JSON with status 200, or status when the definition sets it (200 to 299), or the one setResponseStatus set.

The handler givesThe route answers
a valueJSON, JSON.stringify after a check that refuses what JSON would lose: a Map, a Set, a bigint, a cycle, NaN. The error names the handler and the path, such as posts.ts#getPost returned an instance of Map at output.tags, which JSON cannot carry; return plain data on a route
undefined, null204, no body
a stringa JSON string
a Responsethat response, with the headers set on the event
a Blob, Filethe bytes
a streamtext/event-stream, each chunk JSON, then event: end
redirect(), returned or throwna redirect with Location
a thrown errorJSON, or the HTML error page when the request prefers HTML

A failed input answers 400 with every issue; see Validated routes. A cross-site form post is refused before the router with 403, as for every path.

raw: a plain HTTP endpoint

raw: true hands the request over. The shape stays handler(input, ctx), with input the path and query only and the body unread on event.request: handler({ id }, { event }), and handler(_, { event }) when there is no parameter. There is no 415 rule, no input merge, no JSON check and no output; the value goes through the response rules below, so a string is HTML. method: "*" answers every method. A raw handler has no id endpoint and no stub, and importing one from #server in client code fails: app/x.tsx imports stripeHook from "#server", but it is raw: it answers a URL, not a call.

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

const posts = new Map([["hello", { title: "Hello", body: "First post" }]]);

export const getPost = defineHandler({
  route: "/api/posts/:slug",
  method: "GET",
  raw: true,
  handler(_, { event }) {
    const slug = getRouterParam(event, "slug") as string;
    const post = posts.get(slug);
    if (!post) throw createError({ status: 404, message: "Post not found" });
    return post;
  },
});

Read the request through the event helpers:

HelperDoes
getQuery(event)the query as an object; repeated names are arrays
getRouterParam(event, name), getRouterParams(event)decoded route params
readBody(event)JSON, form or text, by content type; 400 on bad JSON, 413 above server.maxBodySize (1 MiB)
readFormData(event), readBodyBytes(event)the form data, the bytes
readValidatedBody(event, validate), getValidatedQuery(event, validate)validate is a Standard Schema, or a function that returns the value, true/undefined (keep the value read) or false (400); a SilgiError it throws answers as it is, a plain TypeError or RangeError (not a subclass) is a 400 with its message, any other throw is a 400 Validation failed in production (the message is kept in dev) and is logged at warn
getRequestHeader, getRequestHeaders, setHeader, setHeaders, appendHeaderrequest and response headers
getCookie, setCookie, deleteCookiecookies; setCookie(event, name, value, { domain, path, expires, maxAge, httpOnly, secure, sameSite, partitioned })
setResponseStatus(event, code)the response status
sendRedirect(event, to, code = 302)returns a redirect Response. A target on another origin needs sendRedirect(event, to, { external: true }) (the options also take status); without it the call answers a 500. Only http: and https: are sent
getRequestURL(event)the request URL; x-forwarded-host and x-forwarded-proto are used only with server.trustProxy
getRequestIP(event, { xForwardedFor, trustedHops })the client address the server sees (bun: the socket, node: the peer, cloudflare: cf-connecting-ip, netlify: context.ip, vercel: the first x-real-ip value); with xForwardedFor: true, the x-forwarded-for entry trustedHops places from the end (1 to 10, default 1: the last) when there is one. server.trustProxy does not change it.
useGlobalCss(event)the URLs of the app's global CSS, a new array each call: in a build manifest.css (the CSS that app plugins, route middleware and the error page import, not a page's or layout's CSS), in silgi dev the stylesheet links of the dev client tags, and [] for an event the app handler did not create. Use it in a route that renders its own HTML document (below).
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 };
  },
});

A route that returns a full HTML document does not go through the page handler, so nothing links the app's CSS for it. useGlobalCss(event) returns the URLs, already prefixed with app.baseURL and app.buildAssetsDir. It works in handlers, request middleware and an in-process $fetch. A page's own CSS is not in the list in a build; in silgi dev it holds the CSS of every page, so a rule that only a page imports can style the document in dev and not in a build.

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" } });
  },
});

What a raw handler returns

ReturnedResponse
a Responsesent as it is, with the headers set on the event merged in
an object, array, number or booleanJSON
a stringtext/html unless a content type was set
bytes or a Blobapplication/octet-stream unless set
a ReadableStreamstreamed
null or undefined204 with no body
any value with status 204 or 304sent with no body; an answer to HEAD keeps its headers and has no body
a thrown createErrorits status, as JSON or the HTML error page
any other throw500, or the status/statusCode of the thrown value when it is an HTTP error status (400 to 599); the message is the status text in production
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";
  },
});

A raw handler can look at the request before it answers, as this one does for a key:

import { defineHandler } from "silgi/server";

export const secret = defineHandler({
  route: "/api/secret",
  method: "GET",
  raw: true,
  handler(_, { event }) {
    if (!event.request.headers.has("x-api-key")) return new Response("Missing key", { status: 401 });
    return { ok: true };
  },
});

Middleware

The middleware list of defineServer runs before every request that matches, and before every route, handler call and page. defineMiddleware takes a function ({ event, input, context, next }), or an object with a server part (and a client part for handler middleware). Returning nothing continues. Returning a value ends the request with it. await next() runs the rest and returns its response, so code after it sees the answer and can set event.response.headers; a returned Response replaces the answer. next({ context }) merges values into event.context. A throw is the request's error, and a thrown redirect() redirects. An entry { route: "/admin/**", handler } runs only under that path; the pattern is a route rule's (* one segment, a last ** zero or more segments), below app.baseURL, and /admin/** also matches /admin. A request middleware has no client part, because nothing runs it in the browser: list a middleware that has one in handlerMiddleware.

The event.context object carries values on to the handler and to the load files of a page. To type the keys a middleware sets, augment SilgiEventContext (and SilgiRuntimeEnv for the platform bindings on event.runtime.env) with declare module "silgi/server"; see the API reference.

import { defineMiddleware, getRequestHeader } from "silgi/server";

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

A middleware that refuses a request refuses it before the handler, and a route middleware (app/middleware) runs only on a navigation in the browser, so only the lists of defineServer and the middleware of a handler guard anything on the server. The same object can sit in both lists, and a guard that matters is listed in both: the request list protects the document under a path, the handler's list protects the data on every call; see Sessions and security.

The request, anywhere

A helper in server/utils/ has no event argument, and passing one down every call is noise. useEvent() returns the event of the request that is running, so the helper reads it where it is.

import { defineHandler, getCookie, useEvent } from "silgi/server";

export function requestPath(): string {
  return useEvent().url.pathname;
}

export async function currentSession(): Promise<{ id: string; path: string } | null> {
  await Promise.resolve();
  const id = getCookie("sid");
  return id ? { id, path: requestPath() } : null;
}

export const whoAmI = defineHandler({
  method: "GET",
  handler: () => currentSession(),
});

It works in a handler, a handler route, middleware, a load, an app plugin on the server and the page render, and it follows the request across await, timers and event.waitUntil: two requests at the same time never see each other's event. getCookie(name), setCookie(name, value) and deleteCookie(name) without an event use it, and so does a handler called on the server without { event } (Handlers).

Every request runs inside the request context, an AsyncLocalStorage store of the preset. useEvent() throws where no request runs: in module code, in a server plugin, or in a timer a plugin started. In a cached computation it returns the cache's own copy of the event, which carries only the headers varies names (Storage and cache). The error messages are in the API reference.

The context has a small price. The bun preset answering GET /api/hello ran at 14,187 requests a second with it on and 14,453 with it off, 1.8 % fewer (medians of 7 alternating runs). The off build's own runs ranged over 4,615 requests a second, 32 % of its median, because the machine was shared and loaded. So the run measures a cost smaller than its noise, and the context stays on by default. To turn it off, set server.asyncContext to false in silgi.config.ts; useEvent() then throws useEvent() needs the request context; set server.asyncContext: true in silgi.config.ts, unless tracing is on. On Cloudflare the context needs the nodejs_compat compatibility flag. The method and every run are in docs/benchmarks.md of the repository.

Order of a request

  1. The request server hook.
  2. Static files from public/ and the client assets.
  3. The CORS preflight for a cors rule, the origin check, then route rules that redirect or proxy.
  4. The middleware list of defineServer, outermost entry first: each module's list, each layer's from low priority to high, then the app's.
  5. The OpenAPI document and the GraphQL endpoint, when they are on.
  6. A matching server route, a handler's route or a raw one.
  7. A path with routes but none for the method: 405 with allow.
  8. /_silgi/fn/<id>, the endpoint of a handler a page calls.
  9. With app.trailingSlash: "redirect", a 308 to the path without its trailing slash when that path is a page.
  10. A request that does not prefer text/html and matches no page: a JSON 404.
  11. A POST to a page with ?_fn=<id> runs that handler and renders the page (Forms); any other POST to a page answers 405.
  12. The page, or the HTML 404 error page when no page matches.

allow adds HEAD when GET is there and OPTIONS for a path with a cors rule.

Plugins and hooks

A definePlugin(fn) in the plugins list runs once, when the server starts, or at the first request on Cloudflare, Vercel and Netlify. It gets { hooks, runtimeConfig, log, addLogSink, addSpanExporter, addMetricExporter }.

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) });
  });
});

The hooks are request(event), beforeResponse(event, response), afterResponse(event, response), error(error, event), render:html and close. They return nothing and cannot replace the response; beforeResponse may change its headers. A hook that throws is logged and the response still goes out, except request, whose error answers the request. close runs when the bun or node server shuts down: on the first SIGTERM or SIGINT the server stops accepting connections, lets requests in flight finish for up to 10 seconds, runs close and exits with code 0; a second signal exits with code 1. See Deploy.

Server-sent events

createEventStream(event, { heartbeat }) returns push(data, { id, event, retry }), close(), onClosed(callback) and send(). Return send() from the handler. Strings are sent as they are and other values as JSON. The options of push write the id:, event: and retry: lines; a client that reconnects sends the last id back as Last-Event-ID. push returns a promise that resolves when the stream has room again, so await push(…) slows a fast producer to its reader. onClosed runs once when the stream ends, by close(), a closed connection or an aborted request; release timers there.

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();
  },
});

While the stream is open it writes the comment : ping every heartbeat milliseconds (default 15000; false or 0 turns it off; above 2147483647 or not an integer throws), so the runtime and proxies in front do not close a quiet stream. Clients ignore comment lines. A ping is skipped while the client has not read what was sent before, the timer starts when send() is called and stops when the stream closes, the client goes away or the body is cancelled. On the bun preset an event stream has no idle timeout.

A stream still ends at the platform's limit on one response, which Silgi cannot raise:

PresetLimit on one response
bun, nodenone
cloudflarenone while the client stays connected
vercelthe function's maxDuration: 300 s by default, up to 800 s on Pro and Enterprise
netlify60 s, not configurable; a streamed response is also capped at 20 MB

A client that must stay subscribed longer reconnects; an EventSource does so on its own.

WebSockets

A socket is a handler with a route and a websocket key: defineHandler({ route, websocket: { open, message, close, error, origin } }). It runs on the bun preset only; a build for any other preset fails and names the route. A socket has no input, output, events, raw or status, and no stub: importing one from #server fails with <file> imports chat from "#server", but it is a websocket: it answers a URL, not a call. The middleware of the handler and handlerMiddleware run before the upgrade, and a refusal there leaves no socket. Any other key of websocket throws websocket key "x" is not a key; use origin, open, message, close, error.

A browser sends the page's cookies with a WebSocket handshake from any site, so the socket refuses another origin by default. An upgrade passes when it has no Origin header (a non-browser client), when the Origin host is the host of the request (with server.trustProxy, the forwarded host), or when the Origin is in server.originCheck.allow. Anything else gets a 403 with the message WebSocket origin not allowed before open runs, and the server logs a warning once per origin. The origin key changes the rule: it takes "same" (the default), "any", a list of origins such as ["https://admin.example"] (plus an absent Origin), or a function (origin, event) => boolean that decides for every upgrade. An entry that is not an origin, such as "https://a.example/x", throws a TypeError when the handler is defined. silgi dev applies the same check.

import { defineHandler } from "silgi/server";

export const socket = defineHandler({
  route: "/ws",
  websocket: {
    open(peer) {
      peer.send("welcome");
    },
    message(peer, message) {
      peer.send(`echo: ${message}`);
    },
  },
});

Validated routes and OpenAPI

A handler with a route carries its schemas once. input checks the request, types the handler and describes the parameters; output checks the answer and describes the 200; meta describes the operation. The server builds an OpenAPI 3.2 document from its own route list. Silgi has no validator of its own: any library that implements Standard Schema works with no adapter, such as zod, valibot or arktype.

Write a route with a schema

// server/posts/rename.ts
import { createError, defineHandler } from "silgi/server";
import * as z from "zod";

export const renamePost = defineHandler({
  method: "PATCH",
  route: "/api/posts/:id",
  input: z.object({
    id: z.coerce.number().int(),
    notify: z.enum(["yes", "no"]).default("no"),
    title: z.string().min(1),
  }),
  output: z.object({ id: z.number(), title: z.string() }),
  meta: { summary: "Rename a post", tags: ["posts"] },
  async handler({ id, title }) {
    const post = await db.rename(id, title);
    if (!post) throw createError({ status: 404, message: "Not found" });
    return post;
  },
});

id is a number and title a string, with no annotation. id comes from the path, notify from the query and title from the body; the document puts each where it is read. A return value that does not fit output is a type error, and output also runs on the value at run time. A key that is not a route parameter, with GET, HEAD or DELETE, is documented as a query parameter; with the other methods, in requestBody. A route without input and output costs nothing extra per request.

The 400 answer

When input fails, the handler does not run and the request answers 400 with every issue. in names where the key was sent: "params" for a key of the route, "query" or "body" for the rest, and for a key nobody sent "body" where the route reads a body and "query" where it does not. path is an array of strings and numbers, and message is the library's own text.

PATCH /api/posts/abc?notify=maybe
content-type: application/json

{"title":""}
{
  "status": 400,
  "message": "Validation failed",
  "data": {
    "issues": [
      { "in": "params", "path": ["id"], "message": "Invalid input: expected number, received NaN" },
      { "in": "query", "path": ["notify"], "message": "Invalid option: expected one of \"yes\"|\"no\"" },
      { "in": "body", "path": ["title"], "message": "Too small: expected string to have >=1 characters" }
    ]
  }
}

The messages depend on the library. A body that is not valid JSON is the usual 400 of readBody, and one above server.maxBodySize is a 413; both come before validation. A schema whose validate throws is a 500, not a 400. Called by id or from a form, the same failure carries the issues as they came, which useForm reads.

Turn on the document

server.openapi is false by default. true turns it on with defaults; an object sets path, info, servers and any other root field of the document, such as components, security or tags.

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "html",
  server: {
    openapi: {
      info: { title: "Shop API", version: "1.4.0" },
      servers: [{ url: "https://shop.example.com" }],
      components: { securitySchemes: { session: { type: "apiKey", in: "cookie", name: "silgi-session" } } },
      security: [{ session: [] }],
    },
  },
});

The document is served on GET and HEAD at /_silgi/openapi.json, or at path, below app.baseURL. It is built on the first request and kept. When server.openapi is off, the path is not answered and the build carries no OpenAPI code. openapi, paths and jsonSchemaDialect cannot be set in the config.

Silgi gives no UI page; point the viewer you like at the path.

The methods of a route

A route's method is the method of its handler, and the document lists exactly that. meta does not set it. A raw route with method: "*" answers every method, so it has no single method to list: it is documented as get and the server logs one line, once per route and process:

[silgi] openapi: /api/echo answers every method; documented as get, give it a method

Give the route a method to document it and to answer 405 for the others.

Schemas in the document

A schema appears in the document only when it has ~standard.jsonSchema, the Standard JSON Schema side of the interface. Without it the schema still validates, but it is documented as {}, and the first request to the document logs one warning per route and part:

[silgi] openapi: PATCH /api/posts/{id} body schema has no ~standard.jsonSchema; documented as {}

zod and arktype schemas carry it. A valibot schema does not; wrap it with toStandardJsonSchema from @valibot/to-json-schema:

import * as v from "valibot";
import { toStandardJsonSchema } from "@valibot/to-json-schema";

const input = toStandardJsonSchema(v.object({ title: v.pipe(v.string(), v.minLength(1)) }));

input is described as what a client sends, output as what the handler returns.

Server-sent events

events describes the data of each event a route sends with createEventStream. Like output it is never run, and it types nothing: the handler returns the stream's Response. The document lists the success response as text/event-stream. A route has one success body, so output and events together throw events cannot be used with output: a route has one success body. events needs a route, and a raw handler may have it.

// server/ticks.ts
import { createEventStream, defineHandler } from "silgi/server";
import * as z from "zod";

export const ticks = defineHandler({
  method: "GET",
  route: "/api/ticks",
  raw: true,
  events: z.object({ count: z.number() }),
  meta: { summary: "Count to five" },
  handler(_, { event }) {
    const stream = createEventStream(event);
    let count = 0;
    const timer = setInterval(() => {
      stream.push({ count: ++count });
      if (count === 5) {
        clearInterval(timer);
        stream.close();
      }
    }, 1000);
    stream.onClosed(() => clearInterval(timer));
    return stream.send();
  },
});

QUERY routes

method: "QUERY" is a safe method that carries a body, for searches too large for a URL. It needs a route. Its input merges as on POST, the document lists it under the query operation of the path with the input in requestBody, and a 415 sets accept-query.

// server/search.ts
import { defineHandler } from "silgi/server";
import * as z from "zod";

export const search = defineHandler({
  method: "QUERY",
  route: "/api/search",
  input: z.object({ terms: z.array(z.string()), limit: z.number().default(20) }),
  output: z.array(z.object({ id: z.number(), title: z.string() })),
  meta: { summary: "Search posts" },
  handler: ({ terms, limit }) => db.search(terms, limit),
});

meta

meta is plain data that is only read when the document is built.

FieldDoes
summary, description, tags, deprecatedcopied to the operation; without tags the first static path segment is used
operationIdthe operation id; the default is the method and route name, as in patchApiPostsId
security, externalDocs, x-*copied to the operation
requestBodyreplaces the generated request body, for forms or other content types
responsesmerged over the generated ones by status; an entry replaces that status whole
hiddenleaves the route out of the document

WebSocket routes, middleware, plugins, pages and handlers without a route are never listed. Middleware is not read, since its effect cannot be seen from code; describe an auth check with components.securitySchemes and security in the config, or with meta.security and meta.responses.

In tests and scripts

getOpenAPIDocument({ routes, baseURL, config }) builds the document without a server. Each call builds a new one.

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

const ping = defineHandler({ route: "/api/ping", method: "GET", meta: { summary: "Ping" }, handler: () => "pong" });

const document = await getOpenAPIDocument({
  routes: [
    {
      name: "api-ping",
      path: "/api/ping",
      method: "GET",
      fn: { id: "api-ping", name: "ping", file: "server/ping.ts", export: "ping", raw: false },
      handler: async () => ({ default: ping }),
    },
  ],
  config: { info: { title: "Test API", version: "0.0.1" } },
});

console.log(Object.keys(document.paths));

Protect the document

The document lists your whole API to anyone who asks. In production put it behind a request middleware, which runs before the document is answered. The sample answers 401 when DOCS_TOKEN is unset or empty, so a missing variable never opens the document.

import { createError, defineMiddleware, getRequestHeader } from "silgi/server";

export const docsGuard = defineMiddleware(({ event }) => {
  if (new URL(event.request.url).pathname !== "/_silgi/openapi.json") return;
  const token = process.env.DOCS_TOKEN;
  if (!token || getRequestHeader(event, "authorization") !== `Bearer ${token}`) {
    throw createError({ status: 401, message: "Unauthorized" });
  }
});

Cached routes

A GET handler with cache is documented like any other. Validation runs inside the handler, so the first request is validated and a 400 is never stored. A cache hit does not run the handler.

Warning. On a cache hit the handler does not run, so nothing is validated. A header the answer depends on belongs in varies, so it is part of the cache key, and an authorization check belongs in the request middleware: a request without the header can otherwise be answered from the cache.

$fetch

$fetch(url, options) is a small fetch client, available in silgi/server and silgi/app. It takes method, query, body (objects are sent as JSON), headers, baseURL, responseType, signal, timeout and retry. A response that is not 2xx throws a FetchError with status, statusText, data and url. $fetch.raw returns { response, data }. In query, an object (alone or inside an array) is sent as JSON, a Date as an ISO string, null as an empty value and undefined is dropped; an array repeats the key.

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 });
}

On the server, a URL that starts with / is answered in process: the app handler gets a new request, with no socket. Inside a load use ctx.fetch, which also forwards the request's cookie and authorization headers; to call your own handler, import it from #server instead. silgi typegen types calls to your own routes; see Modules.