silgi/app

Code that runs in your pages and in load files, route middleware and app plugins, on the server and in the browser. Hooks that read the page (useData, useHead, useState) are not here: each adapter has its own, see @silgi/<library>.

Loaders

Loader, LoaderContext

Loader<T> is (ctx: LoaderContext) => T | Promise<T>.

LoaderContext fieldType
paramsRecord<string, string | string[]>route params
queryRecord<string, string | string[]>the query string
searchRecord<string, unknown>the query validated by the chain's search schemas (defaults filled); equal to query when no page exports search
urlURLthe request URL
routeResolvedRoutethe matched route
eventSilgiEvent of silgi/serverthe request event; the same cookies and headers the server sees
fetch$Fetch$fetch bound to this request
parent<T>() => Promise<T>the data of the nearest parent loader
setHeaders(headers) => voidheaders for the response
runtimeConfigConfigShape<SilgiRuntimeConfig>the runtime config the loader may see, private keys included; typed from the config once silgi typegen ran
context{ get<T>(key: RouteContextKey<T>): T; set<T>(key: RouteContextKey<T>, value: NoInfer<T>): void }the values the route middleware of this navigation set (createRouteContext)
depends(...keys: string[]) => voiddeclares keys that router.refresh(key) re-runs this load for
untrack<T>(fn: () => T) => Truns fn; the depends and fetch calls it makes synchronously record no dependency, a call after an await inside it does
prime(key: string, value: unknown) => void, or (fn, input, value) => void for the entry of a handler and its inputseeds the async-data entry key with value in the payload, so useAsyncData(key, handler) reads it without running handler on the server, during hydration and after a navigation; when two loaders prime one key the innermost route wins (dev logs both); the value must be serializable

A loader file may export searchKeys, an array literal of string literals (export const searchKeys = ["page", "sort"];, anything else fails the build): its loader's query, url.search, search and route.query, route.search, route.fullPath then hold only those keys, a navigation that changes only other keys keeps its data unless a loader above it runs again.

Every fetch call of a loader is a dependency too: an app path as written, with the call's query applied and without app.baseURL (fetch("/api/x", { query: { a: 1 } }) is /api/x?a=1); any other URL its href.

import type { Load } from "silgi/app";
import { listOrders } from "#server";

export const load = (async ({ parent }) => {
  const { account } = await parent<{ account: { name: string } }>();
  return { orders: await listOrders({ owner: account.name }) };
}) satisfies Load;
import type { Loader, LoaderContext } from "silgi/app";
import type { SilgiEvent } from "silgi/server";

export const load: Loader<{ q: string; path: string }> = (ctx: LoaderContext) => {
  const event: SilgiEvent = ctx.event;
  return { q: String(ctx.query.q ?? ""), path: `${ctx.route.path} ${event.method}` };
};

SharedLoaderContext

The context of the load an x.load.ts file exports. That load runs on the server for the document and in the browser for navigations and refresh(), with no data request for its route. On static output the browser reads the route's _payload.json and never runs it. SharedLoaderContext is LoaderContext with event: SilgiEvent | undefined (undefined in the browser), runtimeConfig the client runtime config on both sides, fetch the request-bound fetch on the server and $fetch in the browser, setHeaders doing nothing in the browser, and signal: AbortSignal. A redirect a handler answers to a call the load made before its first await, or with ctx.signal, is followed when the load ends, under the navigation's redirect-loop guard. A redirect to another origin needs redirect(to, { external: true }). A load is not a guard: it runs in the browser, and a user can skip it. Put the check in the handler it calls.

Load, LoadContext

Load<T> is (ctx: LoadContext) => T | Promise<T>, the type of the load a <page>.load.ts or app/layouts/<name>.load.ts file exports. LoadContext is SharedLoaderContext. A layout's is keyed layout:<name>, and parent() of a page below it gets its value.

export const load = (async ({ params }) => ({ post: await getPost({ id: params.id as string }) })) satisfies Load;

Write satisfies Load, not load: Load: an annotation fixes the return to unknown, and the route's data with it.

ValidationFailure

interface ValidationFailure {
  issues: Array<{ path: Array<string | number>; message: string }>;
  values: Record<string, unknown>;
}

formToObject

Decodes form entries into an object in one pass: a.b and a[0] nest (an index must be below 1000, else 400), a repeated name gives an array, n:<name> gives Number(value) and b:<name> gives true for on or true and false otherwise, "" gives undefined, and a file stays a File. Keys are own properties, so __proto__ is plain data.

function formToObject(form: FormData): Record<string, unknown>

enhance, EnhanceOptions, FormSubmitArgs

Gives a plain <form> the behaviour of the adapters' Form. It sets data-silgi-form and the attributes of the options you give (replace, reset, refresh, restore) and returns a function that removes the callbacks and puts the attributes back. A second enhance of the same form replaces the first. With call the form is not posted at all: on submit it runs onSubmit (cancel() stops it), then call(formToObject(new FormData(form, submitter)), formData). call resolves true after a success, which resets a data-silgi-reset form, and "invalid" after a failure whose data holds issues, which focuses the form's first field with aria-invalid="true". This is what <Form action={fn}> registers for a handler. A submitter with formaction or formmethod goes its own way.

function enhance(form: HTMLFormElement, options?: EnhanceOptions): () => void

interface EnhanceOptions {
  replace?: boolean;
  reset?: boolean;
  refresh?: boolean;
  restore?: boolean;
  onSubmit?(args: FormSubmitArgs): void;
  call?(input: Record<string, unknown>, formData: FormData): Promise<boolean | "invalid">;   // Form action={fn}
}

interface FormSubmitArgs { formData: FormData; submitter: HTMLElement | null; cancel(): void }

onSubmit runs before the request, with the FormData it will send (change it in place); cancel() stops the submission and the browser does nothing either. A callback that throws is logged and does not stop the form. A form with data-silgi-reload or a target is left to the browser and calls nothing.

PendingRun

One running useAction or Form action={fn} call, from run or the submit, a call waiting behind another included. It leaves pendingRuns when its refresh commits, or at once on a fail(), an error, a redirect or reset(). useOptimistic lists these, oldest first.

interface PendingRun<I = unknown> {
  fn: Handler<any, any>;
  instance?: string | number;
  input: I;
  formData?: FormData;           // a Form post
}

createForm, HandlerForm, HandlerFormInstance, HandlerFormOptions, FieldsOf

createForm(fn, options) is the core of useForm(fn); call it directly in a plain page or a test. fields is typed from the handler's input: FieldsOf<I> gives each key of I as a field, so fields.emial fails tsc when input has email; an unknown input gives loose Fields. A field's kind comes from as() alone: as("text") over a numeric schema needs z.coerce.number(), since the build runs no user code for a form. result is what the last run returned, a fail() included; pending follows pendingRuns. for(key) is the store of useAction(fn, { instance: key }): a run or a Form action={fn} instance={key} post of that key moves it. issues() and the posted values come from a fail() or a 400 whose data has issues.

function createForm<I, O>(fn: Handler<I, O>, options: HandlerFormOptions): HandlerForm<I, O>

interface HandlerFormOptions {
  app: object | null | undefined;
  submitted?(): { handlerState?: unknown };   // seeds the store with a no-JS result
  track?(): void;                // called on each read, for a reactive scope
  uncontrolled?: boolean;
}
interface HandlerFormInstance<I, O> { readonly fields: FieldsOf<I>; readonly pending: boolean; readonly result: O | undefined }
interface HandlerForm<I, O> extends HandlerFormInstance<I, O> {
  for(key: string | number): HandlerFormInstance<I, O>;
  subscribe(listener: () => void): () => void;
  version(): number;             // moves with each change, a store snapshot
}

Field, Fields, FieldAttributes, FormIssue

interface Field {
  as(type: string, value?: string): FieldAttributes;
  issues(): FormIssue[];      // issues whose path equals this field's path
  allIssues(): FormIssue[];   // issues at or below it
  value(): unknown;
  set(value: unknown): void;
}
interface FieldAttributes { name: string; type?: string; value?: string; checked?: boolean; defaultValue?: string; defaultChecked?: boolean; "aria-invalid"?: "true" }
type Fields = Field & { [name: string]: Fields }
type FormIssue = { path: Array<string | number>; message: string }

as(type, value?) returns a plain object that spreads in JSX, Vue v-bind, Svelte {...} and html string attributes. With uncontrolled: true (what React's useForm sets, because React reads value without onChange as a read-only field) the value of a text-like field is defaultValue and checked is defaultChecked. name is nameOf(path, kind); type is left out for select and textarea; value is the field's value for text-like types and for select and textarea, the given value for checkbox, radio, submit, button, reset and image, and none for file and password; checked is set for checkbox and radio: true for a boolean checkbox without value whose value is true, else whether the value (or an array value) holds value; "aria-invalid": "true" is set when issues() is not empty. A field's value is what set() wrote, else the values of the instance's last failure (ValidationFailure), else undefined. The names as, issues, allIssues, value and set are the helpers, so a schema field with one of those names cannot be reached through fields.

nameOf, FieldKind

function nameOf(path: ReadonlyArray<string | number>, kind?: FieldKind): string
type FieldKind = "string" | "number" | "boolean"

nameOf(["info", "height"], "number") is "n:info.height"; an index is written a[0], and a boolean field gets b:. formToObject decodes the prefixed names back to numbers and booleans. The kind of a field comes from as alone: number and range are numbers and a checkbox without a value is boolean.

FormDataOptions, FileUpload

The form option of defineHandler and the options of readMultipart. A multipart body goes through Silgi's streaming parser; a body, file or file count above its limit, or more than 1000 text fields, fails with 413, and a malformed body with 400 Invalid multipart body.

interface FormDataOptions {
  maxBodySize?: number;  // bytes, default server.maxBodySize
  maxFileSize?: number;  // bytes of one file, default maxBodySize
  maxFiles?: number;     // default 10
  onFile?(file: FileUpload): File | string | void | Promise<File | string | void>;
}
interface FileUpload { field: string; name: string; type: string; stream: ReadableStream<Uint8Array> }

Without onFile, file parts become Files. With it, each file part is handed over as a stream read from the request as it is pulled; the returned File or string becomes the field's value, returning nothing drops the field, and what the stream leaves unread is skipped and still counts against the limits.

fail, isActionFailure, ActionFailure

A handler's failure: return fail(status, data) from a handler and the caller gets it as a value, not as an error. useAction(fn).failure holds { status, data }, a form posted without JavaScript renders the page again with the failure's status, and a call answers x-silgi-fn: failure. A thrown failure is treated as returned. A status outside 400–499 throws a RangeError.

function fail<D = undefined>(status: number, data?: D): ActionFailure<D>
function isActionFailure(value: unknown): value is ActionFailure
interface ActionFailure<D = unknown> { readonly status: number; readonly data: D }

redirect, isRedirect, SilgiRedirect

redirect(to, options) makes a redirect value. Return it or throw it from a load, from route middleware or from a handler. to is an app path, a URL, or a route location { name, params?, query?, hash? } as navigate takes it: the name is checked against the route names once silgi typegen ran, and the server and the browser turn it into the URL under app.baseURL; an unknown name or a missing param is a 500 on the server and shows the error page in the browser. A string that starts with one / is an app path, as navigate takes it: the server and the browser put it under app.baseURL once, so redirect("/login") under /docs/ answers /docs/login (and "/docs/login" answers /docs/docs/login). A relative target, a URL and a target with external: true are answered as written. options is the status (default 302) or { status?, external?, document?: true, replace?: true }. document: true makes the browser load the target as a document with location.assign (location.replace with replace: true too), so cookies set on the answer are kept and the server renders the target; replace: true alone navigates with history.replaceState. A document request still answers Location. A target on another origin, protocol-relative //x and backslash forms included, needs external: true: without it the server answers 500 without a Location, and the browser shows the error page. Only http: and https: are accepted as a scheme, with or without external. A string to is a typed path (06 §10.3, as a Link checks it): a literal app path no route matches, an external target without external: true, a scheme app.linkProtocols does not list, and with external: true anything but an http(s):// URL or //host fail tsc on that line. isRedirect tells one apart. SilgiRedirect is { to: string; location?: RouteLocation; status: number; external: boolean; document?: true; replace?: true }: for a route location to is "" until the handler or the navigator resolves it.

function redirect<T extends string, E extends boolean = false>(to: CheckedTarget<T, E, true> | RouteLocation, options?: number | { status?: number; external?: E; document?: true; replace?: true }): SilgiRedirect
function isRedirect(value: unknown): value is SilgiRedirect
import { isRedirect, redirect } from "silgi/app";
import type { Load, SilgiRedirect } from "silgi/app";

export const load = (({ params }) => {
  if (params.slug === "old") return redirect("/blog/new", 301);
  if (params.slug === "moved") return redirect({ name: "blog-slug", params: { slug: "new" } });
  if (params.slug === "docs") return redirect("https://docs.example.com/", { external: true });
  return { slug: params.slug };
}) satisfies Load;

export const target = (value: unknown): SilgiRedirect | undefined => (isRedirect(value) ? value : undefined);

navigateTo(to, options?) goes to to, an app path, a URL or a route location. In the browser it calls navigate, or replace with replace: true, and resolves when the navigation ends; a target on another origin throws a TypeError unless external: true; document: true loads the target with location.assign (location.replace with replace) instead. During server rendering, and in route middleware on the server and in the browser, it throws the redirect that redirect(to, { status: redirectCode, external }) returns, so return navigateTo("/login") and await navigateTo("/login") both end the middleware; redirectCode defaults to 302. With open: { target, windowFeatures? }, in the browser it calls window.open(href, target, features) instead and navigates nowhere: href is the target under app.baseURL, and the features are key=value joined with ,, true written yes and false written no ({ noopener: true, width: 400 } is "noopener=yes,width=400"), led by noopener=yes,noreferrer=yes unless windowFeatures names noopener or noreferrer, so the new window gets no window.opener; the external rule still applies. On the server open is ignored (dev logs [silgi] navigateTo() open is ignored on the server). A string to is checked as redirect checks it.

function navigateTo<T extends string, E extends boolean = false>(to: CheckedTarget<T, E> | RouteLocation, options?: { replace?: boolean; external?: E; document?: true; redirectCode?: number; open?: { target: string; windowFeatures?: Record<string, string | number | boolean> } }): Promise<void>

disableScrollHandling

Keeps the scroll position of the client navigation that runs: call it in onNavigate, onAfterNavigate or the setup of the page being navigated to, and that navigation neither scrolls to the top, to its hash nor to a saved position. In onAfterNavigate, which runs after the scroll, it puts the position back before the browser paints. Called at any other time in the browser it does nothing, and dev logs [silgi] disableScrollHandling() was called outside a navigation; on the server it does nothing.

function disableScrollHandling(): void

SilgiSearch

An empty interface that typegen fills in .silgi/types/search.d.ts with declare module "silgi/app": a route name to the output type of its page's search export (03 §4.1), or Record<string, string | string[]> when the page has none. useRoute() narrowed by route.name reads it as route.search. For a .vue or .svelte page the type resolves under vue-tsc and svelte-check; plain tsc sees the string record.

SilgiErrors

An empty interface that typegen fills in .silgi/types/errors.d.ts with declare module "silgi/app": a route name to the Errors type its page exports (export type Errors = { 404: { slug: string } }); a page without one has no entry. useError() of every adapter reads it through RouteError. Nothing runs at run time: Errors is a type, so the page ships no schema.

SilgiLoaders, LoaderData, SilgiRuntimeConfig, SilgiPublicRuntimeConfig, SilgiAppConfig, ConfigShape

Empty interfaces that typegen fills in .silgi/types with declare module "silgi/app". LoaderData<"route-name"> is the data type of that route's load as the payload carries it, Serialized of what load returns, unknown before typegen and for a load typed : Load. SilgiRuntimeConfig, SilgiPublicRuntimeConfig and SilgiAppConfig hold the types of your runtime config, its public part and the merged app config. ConfigShape<T> is T once typegen filled it and Record<string, unknown> while it is empty; it types useAppConfig(), useRuntimeConfig().public and a loader's ctx.runtimeConfig, so a key the config does not have fails tsc after typegen and every key is unknown before.

type ConfigShape<T> = keyof T extends never ? Record<string, unknown> : T

Serialized, JsonSerialized, NotSerializable, SilgiTransported

Serialized<T> is what the payload serializer gives back for T: what useData(), LoaderData, Route.LoadData, a handler call from #server (and so useAsyncData(fn), useAction(fn).state and .failure) hold. Date, URL, RegExp, bigint, undefined, typed arrays and ArrayBuffer stay; Map and Set stay with their members mapped; an Error is Error, a SilgiError<D> is Error & { status: number; data: Serialized<D> }; arrays, tuples and objects are mapped per member, optional fields stay optional. A function, a symbol, a Promise, a Blob and a class with toJSON() become NotSerializable<message>, which has no members and cannot be called, so using it fails tsc with the message, which names the path: not serializable: data.save is a function; the payload cannot carry it (04 §4), not serializable: data.price is an instance of a class with toJSON; register it in app/transport.ts (04 §4.1). A type registered in app/transport.ts stays: typegen fills SilgiTransported in .silgi/types/config.d.ts with what each entry's decode returns (an entry added later shows without typegen). A type a plugin registers with definePayloadReducer reaches the page payload only, so it stays in loader data when the plugin augments SilgiTransported (declare module "silgi/app" { interface SilgiTransported { Decimal: Decimal } }). A class instance with data fields only cannot be told from a plain object, so it passes the types and the serializer throws at run time, naming the path.

JsonSerialized<T> is what a route's JSON answer holds, what $fetch, useFetch and FetchData give for a handler route that is not raw: a toJSON() gives its return type (Date and URL become string), a property typed undefined is dropped and one that may be undefined becomes optional. What defineHandler refuses on a route (Map, Set, RegExp, Error, a binary view, a bigint, a function, undefined as an array item) is a NotSerializable with the message not serializable: output.<path> is <what>, which JSON cannot carry; return plain data on a route, never JSON.stringify's {}. A raw route reads its input another way but answers the same, so its data is JsonSerialized too; a Response, Blob or stream is kept as it is. A recursive type maps at every level; below sixteen levels a message names the path ….

type Serialized<T> = /* T as the payload carries it */;
type JsonSerialized<T> = /* T as a route's JSON answer holds it */;
interface NotSerializable<Message extends string> { readonly refused: Message }
interface SilgiTransported {}   // <app/transport.ts key>: what its decode returns; a plugin adds its own type by augmenting it
import type { SilgiAppConfig, SilgiLoaders, SilgiPublicRuntimeConfig, SilgiRuntimeConfig, LoaderData } from "silgi/app";

export type PostData = LoaderData<"blog-slug">;
export type Loaders = keyof SilgiLoaders;
export type Public = SilgiPublicRuntimeConfig;
export type Private = SilgiRuntimeConfig;
export type AppSettings = SilgiAppConfig;

Middleware

defineRouteMiddleware

Types route middleware: a file in app/middleware/ default-exports it. It receives the target route, the route the user came from (undefined on the server and on the first browser load) and { state, app, event }: the state, the app context and, on the server only, the request's event. Return redirect(...) to redirect, false (abortNavigation()) to cancel, or throw an error to show the error page.

function defineRouteMiddleware(middleware: Middleware): Middleware
import { defineRouteMiddleware, redirect } from "silgi/app";

export default defineRouteMiddleware((to, from, ctx) => {
  if (!ctx.state.get("user")) return redirect(`/login?next=${encodeURIComponent(to.fullPath)}`);
});

defineMiddleware, FunctionMiddleware

A middleware for handlers, the same as defineMiddleware of silgi/server; import it from silgi/app in a file the client also imports: a file that holds only imports and export const <name> = defineMiddleware(…). The client build drops its server part; its client part runs in the stub before the request and adds headers and sendContext. The full type and the request form are under defineMiddleware.

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

abortNavigation

Cancels the navigation when a middleware returns it: return abortNavigation() is the same as return false. No later middleware runs. In the browser the page and the URL stay as they are (back and forward move the URL back), and navigate() resolves. On the server and on the first load of an SPA route there is no page to stay on, so it is a 404 Page Not Found: <path>. With an argument it throws createError(error) instead, which shows the error page.

function abortNavigation(error?: string | CreateErrorOptions): false

Middleware, MiddlewareContext

type Middleware = (to: ResolvedRoute, from: ResolvedRoute | undefined, ctx: MiddlewareContext) => unknown
interface MiddlewareContext { state: State; app: AppContext; event?: SilgiEvent; context: { get<T>(key: RouteContextKey<T>): T; set<T>(key: RouteContextKey<T>, value: NoInfer<T>): void } }
import { createError, defineRouteMiddleware, redirect } from "silgi/app";
import type { Middleware, MiddlewareContext } from "silgi/app";

const guard: Middleware = (to, from, ctx: MiddlewareContext) => {
  if (to.path.startsWith("/admin") && ctx.state.get("role") !== "admin") {
    if (ctx.state.has("role")) throw createError({ status: 403, message: "Admins only" });
    return redirect("/login");
  }
};

export default defineRouteMiddleware(guard);

createRouteContext, RouteContextKey

Makes a typed key for a value that route middleware hands to the loads of the same navigation: the middleware calls ctx.context.set(key, value), a load reads ctx.context.get(key). The value is never serialized, so a class instance or a function can travel, and nothing of it reaches the payload; a component reads what the load returned. A value lives for one run of the loads on one side: middleware and load run together on the server for the document and in the browser for a later navigation, and a refresh() (also the one after a form or useAction call, on focus or by polling) runs the page's route middleware again first, with from the page itself; there a returned false keeps the page as it is and a redirect replaces it. get of a key no middleware set returns defaultValue, and throws No value for route context "<name>" for a key made without one. A load that runs where no middleware ran throws whatever the default: Route context "<name>" is not available while prefetching (the navigation then runs that load after its middleware), and … while hydrating an omitted loader (a serialization: "never" route). In dev a second key with the same name from another file throws Route context "<name>" is created twice. A handler's ctx.context from defineMiddleware is another, unrelated bag.

function createRouteContext<T>(name: string, defaultValue?: T): RouteContextKey<T>
interface RouteContextKey<T> { readonly name: string }
// app/context.ts
export const userContext = createRouteContext<User | null>("user", null);

// app/middleware/auth.ts
export default defineRouteMiddleware(async (to, from, ctx) => {
  ctx.context.set(userContext, await readUser());
});

// app/pages/me.load.ts
export const load = (async (ctx) => ({ name: ctx.context.get(userContext)?.name })) satisfies Load;

Tools

Experimental: needs experimental.webmcp: true. A file in app/tools/ default-exports defineTool({ … }), and the page registers it with the browser's WebMCP model context after hydration, so an AI agent in the browser may call it with the user's session. Silgi adds no confirmation step and no rate limit.

defineTool

Types a tool and returns its argument. name defaults to the file name; execute runs in the page and may use $fetch, $graphql, navigateTo and context.app. What it throws is logged and rethrown to the agent. The type of input is the generic I; Silgi does not infer it from inputSchema.

function defineTool<I extends Record<string, unknown> = Record<string, unknown>, R = unknown>(tool: ToolDefinition<I, R>): ToolDefinition<I, R>

ToolDefinition, ToolContext

interface ToolDefinition<I, R> { name?: string; title?: string; description: string; inputSchema?: ToolInputSchema; execute(input: I, context: ToolContext): R | Promise<R> }
interface ToolContext { signal?: AbortSignal; app: AppContext }

JsonSchema, ToolInputSchema

A plain JSON Schema object, written by hand. There is no validator; the browser and the agent may check the input.

type JsonSchema = { type?: "object" | "array" | "string" | "number" | "integer" | "boolean" | "null" | Array<…>; description?: string; properties?: Record<string, JsonSchema>; required?: string[]; items?: JsonSchema; enum?: unknown[]; [keyword: string]: unknown }
type ToolInputSchema = JsonSchema & { type: "object" }

Routes

withBase, withoutBase

withBase turns an app path into an href under baseURL: withBase("/about", "/app/") is "/app/about" and withBase("/", "/app/") is "/app/". withoutBase is the reverse and keeps ?query and #hash; a path outside the base comes back unchanged. Both return anything that does not start with a single / as given: a URL with a scheme (https:, mailto:), a protocol-relative //…, a ?/#-only target and a relative target (./x, ../x, about).

function withBase(path: string, baseURL: string): string
function withoutBase(path: string, baseURL: string): string

resolveRoute, matchRoute, MatchedRoute, ResolveRouteOptions

resolveRoute returns the href of a named route under app.baseURL, for a server route, a script or an email template; it needs no app context. resolveRoute("blog-slug", { slug: "a b" }) is "/blog/a%20b". query values that are not strings are written with String, objects as JSON, undefined and null are left out; hash takes a leading # or none. An unknown name throws No route named "x" and a missing required param throws Route "blog-slug" needs the param "slug"; after typegen the name and the params are typed by SilgiRoutes, and params is required when the route has a required param. matchRoute takes an href or a URL, ignores its query and hash, strips app.baseURL and matches with the router of the server and the navigator; it returns undefined outside the base, for a malformed escape or when no page matches. Both read #silgi/route-paths, which the build emits with every route's name and path and no page import.

function resolveRoute<N extends keyof SilgiRoutes>(name: N, params?: SilgiRoutes[N], options?: { query?: Record<string, unknown>; hash?: string }): string
function matchRoute(url: string | URL): { name: string; params: Record<string, string | string[]> } | undefined

PageMeta

The type of the meta export of a page. layout picks a layout or turns layouts off with false, middleware is a middleware name, an inline Middleware or an array of them, and ssr: false renders the page in the browser only, and scripts: false makes an html page script-free (no script in its document, so its links are full loads). searchError: "error" | "strip" decides what an invalid query does when a page exports search: "error" (the default) answers 400 Invalid search params with data.issues, "strip" drops the top-level keys the issues name and validates again. staleTime and gcTime are milliseconds that replace app.data.staleTime and app.data.gcTime for the data the browser keeps for this page; the innermost page wins (Data). Other keys are yours. silgi/adapter exports the same type.

import type { PageMeta } from "silgi/app";

export const meta: PageMeta = {
  layout: "plain",
  middleware: ["auth"],
  ssr: false,
};

SilgiLayouts, SilgiMiddlewareNames, SilgiPageMeta

Typegen fills SilgiLayouts in routes.d.ts with the name of each layout (app/layouts/, layers and addLayout), and SilgiMiddlewareNames with each route middleware that is not global (app/middleware/, layers and addRouteMiddleware) and its kebab-case and numeric-prefix aliases. Once they have names, meta.layout is one of them or false, and meta.middleware takes those names beside inline functions: { layout: "admn" } satisfies PageMeta and a global middleware's name fail tsc on their line. A string variable fails too; type it keyof SilgiLayouts. With no layout or no middleware the interface stays empty and the field takes any string.

SilgiPageMeta is empty until the app declares its own keys. Then PageMeta, what a page writes, is the known keys and those, with no other key: a typo such as layou fails, and so does a page that leaves out a required key. useRoute().meta and each matched[].meta read them as optional (title is string | undefined), since a page without a meta export, one written without satisfies PageMeta and the error page have none. Declaring a key closes meta for the whole app, so only the app declares them; a module must not augment SilgiPageMeta. PageMeta is a type, not an interface: interface PageMeta { … } in declare module "silgi/app" fails with Duplicate identifier 'PageMeta'; declare the keys in SilgiPageMeta.

declare module "silgi/app" {
  interface SilgiPageMeta {
    title?: string;
  }
}

ShouldRefreshArgs

What a page's shouldRefresh export gets, after a successful handler call on the page on screen and for refresh(): { currentUrl, nextUrl, defaultShouldRefresh }. Returning false keeps that route's load data; when every route with a load returns false nothing is fetched again.

export function shouldRefresh({ nextUrl, defaultShouldRefresh }: ShouldRefreshArgs): boolean

Page exports onEnter, onStay, onLeave

Route hooks a page module may export, run in the browser only, after a client navigation committed (after afterEach). onLeave(route) runs for each page that left the matched chain, innermost first, with the route it had; then, outermost first, onEnter(route) for each page that entered and onStay(route) for each that stayed while its own params or the query changed. The first page of the document runs onEnter once it hydrated or mounted, and showing the error page runs onLeave for the pages it replaced. A throw or a rejected promise is logged and does not undo the navigation.

export function onEnter(route: ResolvedRoute): void
export function onStay(route: ResolvedRoute): void
export function onLeave(route: ResolvedRoute): void

RouteLocation, SilgiRoutes

RouteLocation is { name, params?, query?, hash? }. Before typegen name is any string; after it, name is a route name and params is checked against that route. SilgiRoutes is the empty interface typegen fills with route names and their params.

AppPath, ExternalURL, LinkTarget, SilgiRoutePaths, SilgiLinkProtocols

Typed paths. SilgiRoutePaths is the interface typegen fills in routes.d.ts with every same-origin path the app answers, as template literal types where a param is ${string}: each page by route name ("blog-slug": `/blog/${string}`; an optional segment gives both forms), and by "<list> <path>" each entry of the client path list (03 §9): a server route that answers GET ("server /api/items/:id"), a public/ file or a directory of many files ("public /favicon.ico", "public /files/**"), and a routeRules pattern with proxy or redirect ("redirect /old/**": `/old` | `/old/${string}`; * is one segment, a last ** any rest). @silgi/i18n adds / under strategy: "prefix" with detect.redirect: "root". SilgiLinkProtocols gets the schemes of app.linkProtocols as keys. AppPath is such a path, alone or followed by ?… or #…; ExternalURL is <scheme>:… for a listed scheme. LinkTarget<T> is what Link to takes: LinkTarget<"/blog/a"> is "/blog/a" | RouteLocation when a Link takes that literal, and LinkTarget alone is string | RouteLocation. Before typegen, and with typescript: { typedPaths: false }, SilgiRoutePaths stays empty and AppPath is string.

Link to, useRouter().navigate, push, replace, prefetch, navigateTo and redirect check a string target where it is written: a literal app path no route can match ("/blgo/a" where only /blog/${string} exists, or a template literal such as `/blgo/${id}`) fails tsc on that line. So does a scheme app.linkProtocols does not list ("javascript:…", "ftp://…"), which the run time refuses too. A string variable, a relative path ("about", "?page=2", "#top"), a template literal a path can match (`/${id}`), a path with a % escape, one trailing slash, //host and a URL with a listed scheme compile; navigate, push, replace, navigateTo and redirect take an external target (a URL, //host, /\host) only with external: true (or a boolean), and redirect takes only an http(s):// URL or //host there. A refused external target names the cause: Argument of type '"https://x.test"' is not assignable to parameter of type '"https://x.test needs { external: true }" | …'. A param is ${string}, which also matches /, so a page [slug] at the root accepts every path. To check a menu of app paths written as data: const menu = [...] satisfies readonly AppPath[]; a component that passes its prop to Link checks it as Link does with function NavItem<T extends string>(props: { to: LinkTarget<T> }). To link to a path the app does not have on purpose, widen it: to={"/missing" as string}.

interface SilgiRoutePaths {}
interface SilgiLinkProtocols {}
type AppPath = string // after typegen: P | `${P}?${string}` | `${P}#${string}`, P the paths of SilgiRoutePaths
type ExternalURL = `${"http" | "https" | "mailto" | "tel" | "sms"}:${string}` // the schemes of app.linkProtocols
type LinkTarget<T extends string = string> = CheckedTarget<T> | RouteLocation

Errors

createError, SilgiError, CreateErrorOptions

createError makes a SilgiError: an Error with status (default 500; only an integer from 400 to 599 is kept, anything else becomes 500), statusText (default: the status text of status; no CR or LF, else a TypeError), message (default: the statusText), data, fatal and an optional cause. A string argument is the message. Nuxt's statusCode and statusMessage are accepted as deprecated inputs for status and statusText (status wins when both are given); in dev the first use of each logs one warning. The type parameter types data: createError<{ field: string }>({ data: { field: "x" } }).data?.field is a string; SilgiError alone has data: unknown. A SilgiError thrown in a loader or page renders the error page with that status; in a server handler it answers JSON.

function createError<D = unknown>(options?: CreateErrorOptions<D> | string): SilgiError<D>
class SilgiError<D = unknown> extends Error { readonly status: number; readonly statusText: string; readonly data: D | undefined; readonly fatal: boolean }
interface CreateErrorOptions<D = unknown> {
  status?: number; statusText?: string; message?: string; data?: D; fatal?: boolean; cause?: unknown
  /** @deprecated use status */ statusCode?: number
  /** @deprecated use statusText */ statusMessage?: string
}
import { clearError, createError, isSilgiError, showError } from "silgi/app";

export function load(id: string) {
  if (!id) throw createError({ status: 400, message: "id is required", data: { field: "id" } });
}

export function describe(error: unknown) {
  return isSilgiError(error) ? `${error.status} ${error.message}` : "unexpected error";
}

export const fail = () => showError(createError({ status: 503, message: "Down for maintenance", fatal: true }));
export const retry = () => clearError({ redirect: "/" });

isSilgiError

Type guard that also works for errors created by another copy of Silgi. With a status it is true only for a SilgiError of that status; with a handler as well, data is typed from what the handler's errors declares for that status, as a call of the handler gives it (Serialized), and is unknown for a status it does not declare. A call of the handler, from a component, on the server or through useAction, rejects with that SilgiError. A $fetch of the handler's route rejects with a FetchError, for which the guard is false; useFetch's error is not narrowed: its data crossed JSON, so a Date in it is a string. Call it with a lambda (list.find((e) => isSilgiError(e))): iterator helpers and Array.from pass an index as the second argument, which reads as a status.

function isSilgiError(value: unknown): value is SilgiError
function isSilgiError<S extends number>(value: unknown, status: S): value is SilgiError & { status: S }
function isSilgiError<S extends number, H extends Handler>(value: unknown, status: S, handler: H): value is SilgiError<Serialized<declared data of S>> & { status: S }
try {
  await createPost({ title });
} catch (error) {
  if (isSilgiError(error, 409, createPost)) showConflict(error.data?.conflictId);
}

showError, clearError, ClearErrorOptions

showError(error) renders the error page without changing the URL. On the server it throws, so the error page answers. clearError({ redirect? }) clears it and navigates to redirect, or renders the current URL again; a redirect on another origin throws a TypeError. Every adapter re-exports showError and clearError.

function showError(error: unknown): void
function clearError(options?: { redirect?: string }): Promise<void>

ErrorPageProps, ErrorStore

ErrorPageProps is the props of an error page: status, message, data, route (the name of the page route the error's URL matches, the leaf one also when a layout or middleware threw; absent when no route matches) and, only in dev, stack. ErrorStore is the browser's error state that useError reads: get(), subscribe(listener), show(error) and clear(options?).

import type { ErrorPageProps, ErrorStore } from "silgi/app";

export function watchErrors(store: ErrorStore, render: (error: ErrorPageProps | undefined) => void) {
  render(store.get());
  return store.subscribe(() => render(store.get()));
}

State and cookies

State

A keyed store. The server's store is serialized into the page and the browser restores it, so a value set while rendering is there after hydration. use(key, init) returns the value and sets it from init only while the key is missing.

State method
has(key), get<T>(key), set(key, value)read and write; setting the same value does not notify
use<T>(key, init?)read, creating the key from init (a value or a function) when missing
reset(key)sets the key to the init its first use call gave (a function is called again), or to undefined when none did
subscribe(key, listener)returns an unsubscribe function
dump()a plain object of every value

defineState, StateKey

defineState<T>(key, init?) returns a StateKey<T> (StateKey<T | undefined> without init), a frozen object that names a state key and its type; useState(userState) of every adapter then gives T with no type argument. init runs once, for the first reader while the key is missing (not in the browser for a key the server sent), and is what clearState resets the key to. The build's key that useState(userState) receives (build.keyedComposables) is ignored, so every call shares the key the object names; clearState(app, userState) clears it, as does a list that mixes keys and strings.

function defineState<T>(key: string, init?: () => T): StateKey<T>
interface StateKey<T> { readonly key: string; readonly init?: () => T }

const userState = defineState<User | null>("user", () => null);

clearState

clearState(app, keys?, { reset = true }) puts the matched state keys back to the init their useState gave; with reset: false they become undefined. keys is a key or a defineState key, a list of them, or a predicate on the key; without it every key is matched. A key the store does not hold is skipped. Subscribers hear of a change as set notifies them. Async data is not touched; use clearAsyncData.

function clearState(app: { state: State }, keys?: string | StateKey<unknown> | Array<string | StateKey<unknown>> | ((key: string) => boolean), options?: { reset?: boolean }): void

callOnce, CallOnceMode

callOnce(app, key, fn, { mode = "render" }) runs fn once for key and returns a promise. "render": once per page load; on the server fn runs and key is written to payload.once (its own payload field, not part of state), and the browser skips every key the server ran, in hydration and in later renders. "navigation": once per request on the server (also recorded in payload.once, so hydration skips it), and once per navigation in the browser: the first navigation after hydration runs it again, and a refresh of the same page does not. A key the server did not run runs on its first browser call. Calls of one key while fn runs share its promise; a rejection (or a synchronous throw) rejects that promise and leaves the key not done, so the next call runs again.

type CallOnceMode = "render" | "navigation"
function callOnce(app: AppContext, key: string, fn: () => unknown | Promise<unknown>, options?: { mode?: CallOnceMode }): Promise<void>

useCookie, CookieRef, CookieJar, UseCookieOptions

A cookie as a small store; the value is JSON by default (see useCookie in the adapters reference for default, encode, decode and readonly), so CookieRef<T> is { get(), set(value | undefined), subscribe(listener) } over T. On the server it reads the request and writes Set-Cookie; in the browser it uses document.cookie. The options are the ones of setCookie in silgi/server, plus app, the app context whose cookies to use; without it the page's app is used. On the server, outside a request, it throws. set(undefined) deletes the cookie. A CookieJar is the storage behind it: get(name, { signed }?), set(name, value, options?), subscribe(name, listener).

useCookie(name, { signed: true }) signs with the app key SILGI_KEY on the server and reads only a value whose signature verifies (a forged or unsigned cookie reads as absent, or as default). A write during the render is signed before the response is sent. Reading a signed cookie with useCookie publishes its value to the page (see below), so read an httpOnly cookie with getSignedCookie on the server instead. The browser cannot sign: the page reads the verified value the server put in the payload (signedCookies: only the signed cookies this render read, and never one this request set with httpOnly; a cookie read after a streamed response started is not in it, and silgi dev warns), never the text of document.cookie, and set there throws TypeError: signed cookies are written on the server only. Change a signed cookie in a handler or loader with setCookie(event, name, value, { signed }).

function useCookie<T = string>(name: string, options?: UseCookieOptions<T>): CookieRef<T>
import { useCookie } from "silgi/app";
import type { CookieRef } from "silgi/app";

export function themeCookie(): CookieRef {
  const theme = useCookie("theme", { maxAge: 60 * 60 * 24 * 365, sameSite: "lax" });
  if (theme.get() === undefined) theme.set("dark");
  theme.subscribe((value) => console.log("theme is now", value));
  return theme;
}
import { useCookie } from "silgi/app";
import type { CookieJar } from "silgi/app";

const store = new Map<string, string>();
const jar: CookieJar = {
  get: (name) => store.get(name),
  set(name, value) {
    if (value === undefined) store.delete(name);
    else store.set(name, value);
  },
  subscribe: () => () => {},
};

export const locale = useCookie("locale", { app: { cookies: jar } });

Request

useRequestEvent, useRequestHeaders, useRequestHeader, useRequestURL, useRequestFetch

Read the request being rendered from an app context. Each takes app first; the adapters export them without it. On the server app.event is the request's event; in the browser there is none. app.event is never serialized into the payload.

function useRequestEvent(app: AppContext): SilgiEvent | undefined
function useRequestHeaders(app: AppContext, names?: readonly string[]): Record<string, string>
function useRequestHeader(app: AppContext, name: string): string | undefined
function useRequestURL(app: AppContext): URL
function useRequestFetch(app: AppContext): $Fetch
ServerBrowser
useRequestEventthe eventundefined
useRequestHeadersevery header by lower-case name, or only names (a name the request lacks is left out){}
useRequestHeaderthat header, or undefinedundefined
useRequestURLgetRequestURL(event): the forwarded host and protocol count only under server.trustProxynew URL(location.href)
useRequestFetchapp.fetch, the request-bound $fetch: it forwards the request's cookie and authorization to in-process calls and to no other origin$fetch

useRequestHeaders() without names includes cookie and authorization; render only what the page may show. A server-rendered value that the browser reads differently (the host behind a proxy without trustProxy) differs after hydration; keep a value that must match in useState.

setResponseStatus, useResponseHeader

Write the response of the request being rendered from an app context. On the server they set app.event.response; in the browser they do nothing and get() returns undefined. A write counts until the document's headers are sent: during a render that is not streamed, and until the shell is flushed in a streamed one. A later write is ignored, and silgi dev logs [silgi] setResponseStatus(404) after the response started streaming on /posts/x; ignored. The last writer wins: a component over its loader, and an error page over both.

function setResponseStatus(app: AppContext, status: number, statusText?: string): void
function useResponseHeader(app: AppContext, name: string): { get(): string | undefined; set(value: string | undefined): void }

setResponseStatus throws a RangeError for a status outside 200 to 599 and a TypeError for a CR or LF in statusText. set(undefined) deletes the header.

refreshCookie

Re-reads document.cookie for name and notifies that name's useCookie subscribers when the value changed since they last saw it. Use it after code outside the app wrote the cookie. Where the browser has cookieStore and app.cookieStore is true (the default), a change event calls it for each changed name, so another tab's write shows without a call; the app's own write is not announced twice. document.cookie never holds an HttpOnly cookie, so neither this nor the event can read one. On the server it does nothing. options.app picks the app context; without it the page's app is used.

function refreshCookie(name: string, options?: { app?: { cookies?: CookieJar } }): void

updateAppConfig

Deep-merges patch into the app config (plain objects merge; arrays and every other value replace) and notifies every useAppConfig() consumer. The config object is replaced, never changed in place, so the object the build made is untouched. On the server it changes that request's config and payload.config.app only. Without app it uses the page's app in the browser and throws on the server.

function updateAppConfig(app: AppContext, patch: Record<string, unknown>): void
function updateAppConfig(patch: Record<string, unknown>): void

Fetching

$fetch, createFetch, FetchError, CreateFetchOptions, AppFetch, FetchContext, FetchHook

An ofetch-like client. On the server a URL starting with / is answered in process, without a socket. Only a request-bound client forwards the request's cookie and authorization headers: useFetch, which uses useApp().fetch, a loader's ctx.fetch, and createFetch({ event }); the bare $fetch does not. In the browser, and for any other URL, it is fetch. createFetch() with no options always uses fetch; local, event and cookies are for the server. With cookies, an in-process call sends cookies.header() as its cookie and hands the answer's set-cookie lines to cookies.forward(lines, url, method); useFetch, useApp().fetch and a loader's ctx.fetch use it to add those cookies to the page response. A call without baseURL puts app.baseURL before a URL starting with / (not //), so with app.baseURL: "/base/" $fetch("/api/x") requests /base/api/x in process and in the browser; pass baseURL: "/" for a path that already has the base. An absolute URL is unchanged. createFetch({ baseURL }) sets that default for a client of your own; it is a string or a function read on every call. In the browser a call of $fetch or of a createFetch client whose method is not GET drops the client loader-data cache (app.data) when it is sent and when it ends. A response outside 200 to 299 throws a FetchError carrying status, statusText, data (the parsed body) and url. $fetch.raw returns { response, data }. The response is parsed as JSON when its type says so, else as text, unless responseType says otherwise.

const $fetch: $Fetch
function createFetch(options?: CreateFetchOptions): $Fetch
interface CreateFetchOptions { local?: () => AppFetch | undefined; event?: SilgiEvent; baseURL?: string | (() => string | undefined); cookies?: { forward(lines: string[], url: string, method: string): void; header(): string | null }; hooks?: () => Hooks | undefined }
type AppFetch = (request: Request) => Promise<Response>
class FetchError<T = unknown> extends Error { status: number; statusText: string; data: T; url: string }
import { $fetch, FetchError, createFetch } from "silgi/app";
import type { FetchOptions } from "silgi/app";

const options: FetchOptions = { query: { page: 2 }, retry: 1, timeout: 4000 };

export async function loadPosts() {
  try {
    return await $fetch<Array<{ title: string }>>("/api/posts", options);
  } catch (error) {
    if (error instanceof FetchError) return { status: error.status, url: error.url };
    throw error;
  }
}

const api = createFetch();
export const created = () => api.raw<{ id: string }>("https://api.example.com/posts", { method: "POST", body: { title: "Hi" } });

FetchData, RouteInput, SilgiFetchRoutes, FetchCheckedInput, FetchInputField

FetchData<U, M = "GET"> is the data the server route for URL U and method M answers, as $fetch types it. U resolves as the router resolves it: its path (cut at ? or #) is looked up exactly, else in the dynamic routes in the router's order, where at each segment a static segment wins over a mixed one (:name.xml), a parameter, an optional one and a catch-all. So FetchData<"/api/posts/new"> is the static route even when /api/posts/:id exists, /api/posts/1/comments does not match /api/posts/:id, and /v1/u/a/b matches /v1/u/:a/:b before /v1/u/**:rest. On the matched path it takes the route of method M, HEAD as GET, or of any method; a path without that method answers 405 and gives unknown, as does a URL no route matches and any URL before silgi typegen ran. silgi typegen fills SilgiFetchRoutes in .silgi/types/server.d.ts.

RouteInput<U, M = "GET"> is what that route's $fetch call takes as query (GET, HEAD, OPTIONS, CONNECT, TRACE, and a raw handler, which every method: "*" route is) or body (the other methods): the handler's input as it is sent (StandardSchemaV1.InferInput, so a key with a default is optional), without the route's parameters. RouteInput<"/api/posts", "POST"> is { title: string } for a handler whose input has title: string. It is unknown for a handler without input and for a URL no route matches. A query field keeps the schema's type (page: number), but the query carries strings: a GET schema read through $fetch must coerce strings, and the types cannot check that.

$fetch checks body and query against it only when the call names a literal URL without ? that a route matches, a literal method (or none, which is GET; compared without case, HEAD as GET) and no baseURL. Then a wrong or unknown field, a wrong type or a missing required field in the options passed fails at the call, and so does a method the matched path does not take (OPTIONS excepted, which CORS answers). Every other call is typed as FetchOptions and never refused: a URL in a string variable, a union or a generic parameter, a URL with a ?, a method in a string variable, options typed FetchOptions, a baseURL on the call, a $fetch.create with a baseURL, method, query or body default, and a call with a type argument ($fetch<Post>(url)), whose result is Post. A generic wrapper that passes an object literal (<U extends string>(url: U) => $fetch(url, { headers })) passes a FetchOptions variable instead, or calls $fetch<T>.

FetchCheckedInput<U, M, B, R = SilgiFetchRoutes> is the rule above as a type (R is the route table, as in $Fetch<R>), for code that wraps $fetch the way the adapters' useFetch does: { key, input } for a call that is checked, undefined for one that stays loose, never for a refused method. FetchInputField<I, K, V = I> is the object holding that key with the value type V, required unless I accepts {} or undefined.

type FetchData<U extends string, M extends string = "GET"> = unknown
type RouteInput<U extends string, M extends string = "GET"> = unknown
type FetchCheckedInput<U extends string, M extends string = "GET", B = undefined, R = SilgiFetchRoutes> = undefined | never | { key: "query" | "body"; input: unknown }   // undefined: a loose call; never: a method the matched path does not take; else what the call is checked against
type FetchInputField<I, K extends string, V = I> = { [key in K]?: V } | { [key in K]: V }   // optional when I accepts {} or undefined
interface SilgiFetchRoutes {
  static?: { [url: string]: { [method: string]: { data: unknown; input: unknown; key: "query" | "body" } } };   // "*": any method
  dynamic?: Array<Array<{ path: string[]; methods: { [method: string]: { data: unknown; input: unknown; key: "query" | "body" } } }>>;   // in the router's order, 500 per chunk
}

$Fetch, FetchOptions, FetchResult, ResponseType

interface $Fetch<R = SilgiFetchRoutes> {
  <T>(url: string, options?: FetchOptions): Promise<T>;   // with a type argument only
  <U extends string, M extends string = "GET", B extends string | undefined = undefined>(url: U, options?: FetchOptions & { method?: M; baseURL?: B }): Promise<FetchData<U, M>>;   // body and query checked as RouteInput<U, M> (see above)
  raw<T = unknown>(url: string, options?: FetchOptions): Promise<FetchResult<T>>;
  create(defaults?: FetchOptions, options?: { fetch?: $Fetch }): $Fetch;   // $Fetch<{}>, which checks nothing, when defaults has a baseURL, method, query or body
}
interface FetchContext { request: string; options: FetchOptions & { method: string; headers: Headers }; response?: Response; error?: unknown }
type FetchHook = (ctx: FetchContext) => void | Promise<void>
type ResponseType = "json" | "text" | "blob" | "arrayBuffer" | "stream"
interface FetchResult<T> { response: Response; data: T }

A URL that matches a server route is typed by that route once silgi typegen ran; any other URL gives unknown, so give a type argument ($fetch<User>(url)) or narrow the result.

FetchOptionsType
methodstringdefault GET
queryRecord<string, unknown>added to the URL
bodyunknowna plain object, array, number or boolean is sent as JSON
headersHeadersInit
baseURLstringjoined before relative URLs
responseTypeResponseType
signalAbortSignal
timeoutnumbermilliseconds
retrynumbertimes to retry when the request cannot be completed or the answer's status is in retryStatusCodes; default 1 for GET, else 0. An abort, a timeout and a ReadableStream body are never retried
retryDelaynumber | (ctx: FetchContext) => numbermilliseconds to wait before a new attempt, default 0; an answer's Retry-After (seconds or an HTTP date) of at most 60 s replaces it, and a longer one is not retried
retryStatusCodesnumber[]default [408, 409, 425, 429, 500, 502, 503, 504]
onRequestFetchHook | FetchHook[]runs before the request is built; may change ctx.options (headers, query, body, method) and ctx.request
onRequestErrorFetchHook | FetchHook[]the call threw (ctx.error); runs before a retry or the rethrow
onResponseFetchHook | FetchHook[]the answer arrived (ctx.response, body not read)
onResponseErrorFetchHook | FetchHook[]the status is outside 200 to 299; ctx.error is the FetchError
ignoreResponseErrorbooleanreturn the parsed body of an error status instead of throwing
parseResponse(text: string) => unknownreplaces JSON.parse for responseType: "json"

A hook that throws rejects the call with its error and is not retried. ctx.options.headers is a copy for each call, so a hook's change never reaches the options object you passed or another call. $fetch.create(defaults, { fetch? }) returns a $Fetch whose calls merge defaults under their own options: the call's option wins, headers merge by name, query by key, and hook arrays concatenate with the defaults' hooks first. defaults is copied when create is called. fetch is the client to build on, default the one create is called on; the result has create too, and keeps the typed routes of $fetch. On the server, the fetch:request and fetch:response server hooks run for the global $fetch, a loader's ctx.fetch and useFetch.

$graphql, TypedDocument

Runs one of the app's operations from app/graphql/**/*.graphql. Import the operation from #silgi/graphql-operations, which exports one TypedDocument per operation, named after it: its documentId, operationName and kind, never its text. $graphql sends { documentId, variables } to the GraphQL endpoint (server.graphql.path under app.baseURL) through $fetch, so during SSR it is answered in process. It sends POST unless method: "GET" is given, which only a query may use. It returns the body of any GraphQL response, whatever its status, and throws a FetchError only when the response is not one. Data and Variables come from typegen; the variables argument is required when the operation has a required variable.

function $graphql<Data, Variables>(document: TypedDocument<Data, Variables>, variables?: Variables, options?: GraphQLRequestOptions): Promise<GraphQLResponse<Data>>
interface TypedDocument<Data, Variables> { readonly documentId: string; readonly operationName: string; readonly kind: "query" | "mutation" | "subscription" }
import { $graphql } from "silgi/app";
import { PostPage } from "#silgi/graphql-operations";

const { data, errors } = await $graphql(PostPage, { id: "1" });

GraphQLResponse, GraphQLResponseError, GraphQLRequestOptions

interface GraphQLResponse<Data> { data?: Data | null; errors?: GraphQLResponseError[]; extensions?: Record<string, unknown> }
interface GraphQLResponseError { message: string; locations?: Array<{ line: number; column: number }>; path?: Array<string | number>; extensions?: Record<string, unknown> }
interface GraphQLRequestOptions { method?: "GET" | "POST"; headers?: HeadersInit; signal?: AbortSignal }

Async data

clearAsyncData

clearAsyncData(app, key?) resets entries of an app context, one key, a list, or all, to idle with default(), aborting their runs; an entry no one subscribes to is deleted.

function clearAsyncData(app: { asyncData: AsyncData }, key?: string | string[]): void
function clearAsyncData(app: { asyncData: AsyncData }, fn: HandlerReference, input?: unknown): void

AsyncData, AsyncDataEntry, AsyncDataOptions, AsyncDataStatus, PickFrom

AsyncDataStatus is "idle" | "pending" | "success" | "error" | "redirect". An AsyncDataEntry<T> is a snapshot: data, error, status, redirect and promise, which resolves when the run ends and never rejects. A handler that throws redirect() leaves data as it was and error undefined, and the entry holds status: "redirect" and redirect: { to, status }. In a production build the error an entry holds, and the one the server HTML and the payload show, is a createError you threw, kept whole; any other error becomes a masked 500 Internal Server Error (or its own HTTP status with that status's text) with no data, and the original is logged on the server. AsyncDataOptions<T, R> has server (false: the browser runs the handler), lazy, default, transform, immediate (false: a new entry stays idle until refresh) and dedupe ("cancel", the default, or "defer": a refresh while a run is pending returns its promise) and pick (top-level keys of an object result to keep, applied after transform; a result that is not a plain object is stored as is). PickFrom<T, K> is the type of a picked result, Pick<T, K>, and T when no key is picked. The handler is called with { signal }, an AbortSignal aborted when the run is superseded, cleared or purged.

AsyncData method
fetch(key, handler, options?)returns the entry, starting the handler if the key is new; with cached in the options (a component's first call), a value is stored as success without running the handler and undefined runs a finished entry again. Subscribers hear of these changes in a microtask
hydratingtrue from restoring the server's entries until app:mounted
endHydration()ends hydration just before app:mounted: sets hydrating to false and starts the runs of the server: false entries first called while hydrating, which stayed idle so the first render matched the server's
hasDeferred()whether a server: false entry was called and left idle for the browser; the server then writes $deferred: true into the page data
get(key), has(key), isLazy(key)read
refresh(key, handler?)run again, with handler replacing the stored one when given; resolves when the new run ends
clear(key?)reset entries to idle
setData(key, data)replaces data of the entry and notifies its subscribers, leaving status and error; a key with no entry does nothing
onRefresh(key, hook), refreshHook(key), hookedKeys()what router.refresh(key) runs: an adapter registers a hook per mounted useAsyncData key; onRefresh returns the unregister function
subscribe(key, listener)returns an unsubscribe function; in the browser an entry is deleted a microtask after its last subscriber left, unless one came back or the app is hydrating
settled()resolves when no entry is pending
dump()what the server writes into the page

Head, HeadEntry

The head manager that useHead pushes into. push(input) adds an entry and returns { patch(input), dispose() }. render() returns the server HTML parts. With document, apply() brings the real document in line with the entries; without it, apply has nothing to change.

interface Head { push(input: HeadInput): HeadEntry; render(): HeadRender; apply(): void }

HeadInput

The input of useHead and Head.push. Entries merge: the later one wins for the same key, and htmlAttrs and bodyAttrs merge per attribute. A meta tag whose name or property is an arrayable key (og:*, article:*, book:*, profile:*, author:*, theme-color, google-site-verification, twitter:image, twitter:image:*) may repeat within one entry, and a later entry replaces the whole group. See Head for the key rules.

KeyType
titlestring
titleTemplatestring | ((title?: string) => string)
meta, linkArray<Record<string, string> & { key?: string }>
scriptArray<Record<string, string> & { innerHTML?: string; key?: string }>
styleArray<{ innerHTML: string; key?: string }>
htmlAttrs, bodyAttrsRecord<string, string>

HeadRender, HeadTag, HeadDocument, HeadElement

render() returns a HeadRender: htmlAttrs and bodyAttrs as attribute text, each ending in data-silgi-head-attrs="<names>" (the attribute names written, lower-case, sorted, joined by ,) when it holds any, so the first apply() in the browser owns them and removes the marker, and head, a list of tags as HTML. Every tag Silgi writes carries the attribute data-silgi-head (HEAD_MARK of silgi/adapter), so the browser can find and replace them. HeadTag is { tag: "meta" | "link" | "script" | "style"; attrs; innerHTML? }. HeadDocument and HeadElement are the small part of the DOM that apply() needs, so it can run against a document or a test double.

SeoMetaInput

The flat keys of useSeoMeta. SeoMetaInput has title, titleTemplate, description, robots, author, keywords, themeColor, colorScheme, og* (ogTitle, ogDescription, ogImage, ogImageAlt, ogUrl, ogType, ogSiteName, ogLocale) and twitter* (twitterCard, twitterTitle, twitterDescription, twitterImage, twitterImageAlt, twitterSite, twitterCreator).

Server-only and client-only code

serverOnly, clientOnly, isomorphic

serverOnly(fn) returns fn on the server; in the browser the build rewrites the call to a function that throws serverOnly() function called in the browser. clientOnly(fn) is the mirror: it throws clientOnly() function called on the server on the server. isomorphic({ server, client }) is the server value on the server and the client value in the browser, written as an object literal so the build keeps only one of the two. After the rewrite, every import that nothing references any more is removed, so db in serverOnly(() => db.query()) is not in the client bundle. A call the build did not see (a call from a package, an isomorphic argument that is not an object literal) does the same at run time and keeps its imports.

function serverOnly<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R
function clientOnly<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R
function isomorphic<T>(implementations: { server: T; client: T }): T
import { clientOnly, isomorphic, serverOnly } from "silgi/app";
import { db } from "./app-sides-db.ts";

export const readRows = serverOnly(() => db.query("select * from rows"));

export const focusField = clientOnly((id: string) => document.getElementById(id)?.focus());

export const now = isomorphic({
  server: () => Date.now(),
  client: () => performance.timeOrigin + performance.now(),
});

The build rewrites calls in the files of your app and of its layers, imported from silgi/app, under their own name, renamed or through import * as. A module that exports a helper of silgi/app again fails the build: import it from silgi/app where you call it.

Import guards, silgi/server-only, silgi/client-only

The client build fails, naming the file, the reason and the chain of imports that reached it (<file> is server-only (<reason>) and cannot be imported from client code: app/pages/index.ts → app/utils/a.ts → <file>), for a .server or .server.<ext> file (the suffix that marks a module server-only), any file under serverDir (a handler reaches the client only as a stub through #server, and the message then adds client code reaches a handler through "#server"), any file in a folder named server or .server below srcDir or shared/, silgi/server, a node: or bun: specifier, a module that imports silgi/server-only or server-only, and a specifier matched by build.importProtection.client.deny. The server build fails for a module that imports silgi/client-only or client-only and for build.importProtection.server.deny, and turns every *.client.<ext> module into a module with the same exports, all undefined. A match of a deny list reads <file> is refused by importProtection.server.deny and cannot be imported from server code: <chain>, or importProtection.client.deny and client code. silgi dev shows the same message in the overlay. The markers need no package: both names resolve to an empty module on their own side.

The app context and plugins

AppContext, AppContextBase, AppConfigs

The object that plugins and hooks receive: one per request on the server, one per page load in the browser. Apps get it from useApp() or a plugin; adapters and tests make one with createAppContext of silgi/adapter.

AppContext is AppContextBase & SilgiAppProvides; AppContextBase has these fields.

AppContextBase field
statethe State store
asyncDatathe AsyncData store
hooksthe app hooks, see AppHooks
config{ public, app, runtimeApp? }: the public runtime config, the app config, and the base URL and assets directory that runtimeConfigOf returns as app
headthe Head manager
cookiesthe request's or the browser's cookies
fetchon the server, a $fetch bound to the request: a URL starting with / carries its cookie and authorization; absent in the browser. Every adapter's useFetch uses it
errorthe browser's ErrorStore; absent on the server
maskErroron the server, what a render error becomes before a component or the payload shows it (see publicError of silgi/adapter); absent in the browser
adapterthe UI library's app instance, set before the first render: Vue's App, null in the other adapters
plugin provide keystyped from SilgiAppProvides once typegen ran; a key no plugin provides fails tsc (TS2339)

ClientRuntimeConfig, RuntimeApp

What useRuntimeConfig() of every adapter returns: public is the public runtime config with the SILGI_PUBLIC_<KEY> overrides applied, typed as ConfigShape<SilgiPublicRuntimeConfig> (a misspelt key fails tsc once silgi typegen ran), and app is { baseURL, buildAssetsDir } of the config, each with a leading and a trailing slash. It is the same on the server and in the browser, so a component hydrates with what the server rendered, and it never holds a private key.

interface ClientRuntimeConfig { public: ConfigShape<SilgiPublicRuntimeConfig>; app: RuntimeApp }
interface RuntimeApp { baseURL: string; buildAssetsDir: string }

defineAppConfig

Types app/app.config.ts. The object is public: it is sent to the browser.

function defineAppConfig<T extends Record<string, unknown>>(config: T): T

defineSearchCodec, SearchCodec

Types app/search.ts, whose default export replaces the search codec of the app on the server and in the browser. parse turns the search of a URL (no leading ?) into the object the page's search schemas validate (or, without a schema, useRoute().search); stringify writes an object back, for Link query, navigate({ query }) and searchRetain. The default codec is flat URLSearchParams; a key that the search schema's input JSON Schema types as object, array, number or boolean is written as JSON and parsed back.

function defineSearchCodec(codec: SearchCodec): SearchCodec
interface SearchCodec {
  parse(search: string): Record<string, unknown>;
  stringify(value: Record<string, unknown>): string;
}

A page module may export searchRetain: string[]: those keys, when the page on screen has them, are added to every Link and navigate() target built from that page or its children, unless the target sets them. For Link query and navigate({ query }) the keys the updater does not set are carried too. A parse that throws makes the search invalid (a 400, as a schema failure).

definePlugin, AppPlugin, AppPluginResult, AppProvides, ProvidesOf, SilgiAppProvides

A plugin is a function that receives the app context. It runs once per request on the server and once per load in the browser, in file name order. A returned provide object puts its keys on the app context; a key the context already has is an error.

function definePlugin<P extends AppProvides = {}>(plugin: AppPlugin<P>): AppPlugin<P>
type AppPlugin<P extends AppProvides = {}> = (app: AppContext) => void | AppPluginResult<P> | Promise<void | AppPluginResult<P>>
interface AppPluginResult<P extends AppProvides = AppProvides> { provide?: P }
type AppProvides = Record<string, unknown> & { [K in keyof AppContextBase]?: never }
type ProvidesOf<T> = T extends AppPlugin<infer P> ? P : {}
interface SilgiAppProvides {}

definePlugin keeps the type of what the plugin provides. Typegen writes .silgi/types/plugins.d.ts, which makes SilgiAppProvides extend ProvidesOf<typeof plugin> of each file in app/plugins/, so useApp().track has the provided function's type and a misspelt key fails tsc. A plugin that provides nothing adds nothing. A plugin that provides a key AppContextBase already has (hooks, head, state, ...) fails typegen with a message naming the plugin and the key. Before typegen ran SilgiAppProvides is empty, so an app that reads a provided key fails tsc until silgi typegen, silgi dev or silgi build has run.

import { definePlugin } from "silgi/app";

export default definePlugin((app) => {
  app.hooks.hook("page:finish", (route) => {
    console.log(`rendered ${route.path}`);
  });
  return { provide: { track: (event: string) => console.log(`track ${event}`) } };
});
import { definePlugin } from "silgi/app";
import type { AppPlugin, AppPluginResult } from "silgi/app";

const result: AppPluginResult = { provide: { now: () => Date.now() } };
export const plugin: AppPlugin = async () => result;

export default definePlugin(plugin);

Transport, definePayloadReducer, definePayloadReviver

Custom types for the serializer that writes the payload, payload files and the inputs and answers of handlers. A reduced value travels as ["$P", name, data], where data is serialized by the same rules.

type Transport = Record<string, { encode(value: unknown): unknown | false; decode(data: any): unknown }>
function definePayloadReducer(app: AppContext, name: string, reduce: (value: unknown) => unknown): void
function definePayloadReviver(app: AppContext, name: string, revive: (data: any) => unknown): void

app/transport.ts exports transport = { Money: { encode, decode } } satisfies Transport. The build registers it on both sides at load, so it covers every place the serializer runs; the files of the layers merge by key, and a key in two files fails the build with transport "Money" is defined in app/transport.ts and layers/base/app/transport.ts. The file is client code: importing a *.server.ts file from it fails the client build.

encode (and a reducer) runs for every value that is not a primitive, before the built-in types: transport entries first, then plugin reducers in registration order. The first that returns something other than false wins. Shared references to one value keep their identity; a reference back to the value inside its own data throws.

The two functions register a type from an app plugin, on the app context of the side that runs it. On the server plugins run only for page renders, so a plugin's type reaches the page payload; a type that must also reach handler calls belongs in app/transport.ts. For loader data to keep the type in TypeScript, the plugin augments SilgiTransported (Serialized). In the browser a plugin's reviver also revives the first payload: until the plugins ran, its values are placeholders. What the browser sends uses app/transport.ts only, and a request carrying a type the server cannot revive answers 400 payload type "Tag" has no reviver on the server; define it in app/transport.ts.

CaseResult
a name registered twice, or already in app/transport.tsError: payload type "Money" is already registered
a name not matching /^[A-Za-z_$][\w$]*$/TypeError: payload type name "x y" must match /^[A-Za-z_$][\w$]*$/
encode throwsTypeError: payload type "Money" could not reduce the value at data.price: <message>
encode returns a function, a symbol or a class instanceTypeError: Cannot serialize a function at data.price.<payload Money>.fn
no reviver for a name in the payloadthe data is the value; the dev client logs once [silgi] payload type "Money" has no reviver; the value stays plain data

useHydration

Hands a value from the server render to the browser outside useState. Called in an app plugin.

function useHydration<T>(app: AppContext, key: string, get: () => T, set: (value: T) => void): void

The server calls get() after the render and stores the result in payload.hydration[key], its own field of the payload, so useState and clearState never see it. In the browser set(value) is called while the plugin runs, before the first render, when the key is in the payload. Nothing is called on a client navigation, and a page that is not server-rendered has no value. The value is serialized like the rest of the payload, custom types included; one that cannot be serialized fails the render naming payload.hydration.<key>.

CaseResult
a key used twice in one app contextError: useHydration key "theme" is already used

AppHooks, Hooks, PageHookInfo, BeforeNavigateInfo

The hooks on app.hooks, which is a Hooks<AppHooks>: hook(name, fn) and hookOnce(name, fn) register a function and return a function that removes it, callHook and callHookParallel call the registered functions, and removeHook(name, fn) removes one.

HookCalled withWhen
app:createdappafter every plugin ran
app:adapterinstance, apponce per app context, before the first render: instance is app.adapter, so a Vue app can instance.use(createPinia()); a throw fails the render
app:mountedappin the browser, after the first render
page:beforeNavigate{ from, to, type }in the browser, before the guards of a navigation, before onBeforeNavigate; it cannot cancel. A BeforeNavigateInfo: type is the NavigationType, to null for leave and a full load
page:startroute, infoa page render or a browser navigation begins; in the browser info is a PageHookInfo: { action, startTime }, where action is "push", "replace", "pop" or "refresh" and startTime the performance.now() the navigation started at; refresh: true is also set for refresh() and the revalidation after an action
page:finishroute, infoit ends; in the browser info also has duration in ms
page:renderedroutein the browser, after a client navigation committed and scrolled, before onAfterNavigate
page:errorerrorit fails
app:errorerror, { source }in the browser, an error reached the navigator, the adapter's root error hook, a window error event or an unhandled promise rejection; source is "render", "navigation", "plugin", "hook", "chunk", "window" or "rejection". A hook that takes only error keeps working. The window sources neither prevent the default nor show the error page
app:chunkError{ error, url, stage, reload }in the browser, a page, layout, app-root or first-route chunk failed to load twice; stage is "startup" or "navigation", reload whether Silgi now reloads url (once per URL and 10 s)
import { definePlugin } from "silgi/app";
import type { AppHooks } from "silgi/app";

const onError: AppHooks["page:error"] = (error) => console.error(error.status, error.message);

export default definePlugin((app) => {
  app.hooks.hook("page:start", (route) => console.log("to", route.path));
  app.hooks.hook("page:error", onError);
});