silgi/adapter
What an adapter package implements. You only need these to write an adapter, as @silgi/react and the others do. An app does not import from here.
defineAdapter
Returns the adapter unchanged. Export the result as the default export of @silgi/<library>/adapter.
function defineAdapter(adapter: Adapter): Adapter
import { defineAdapter } from "silgi/adapter";
import type { Adapter } from "silgi/adapter";
const adapter: Adapter = {
name: "demo",
extensions: [".demo"],
clientEntry: "@silgi/demo/runtime/client",
serverEntry: "@silgi/demo/runtime/server",
peers: ["demo-lib"],
errorPage: "@silgi/demo/error-page",
};
export default defineAdapter(adapter);
Adapter
| Field | Type | |
|---|---|---|
name | string | the framework value |
extensions | string[] | page file extensions the adapter owns |
clientEntry, serverEntry | string | module specifiers of the runtime entries |
peers | string[] | peer packages the app must install |
plugins | (ctx: AdapterBuildContext) => BunPlugin[] | compile plugins, optional |
jsx | { runtime: "automatic"; importSource: string; development?: boolean } | optional |
errorPage | string | the default error page, optional |
AdapterBuildContext
Given to plugins. target is "client" or "server", dev is true under silgi dev, rootDir and buildDir are absolute, and components lists app/components/** by name and file when the components option is on.
import { defineAdapter } from "silgi/adapter";
import type { AdapterBuildContext, ResolvedRoute } from "silgi/adapter";
const label = (ctx: AdapterBuildContext) => `${ctx.target}${ctx.dev ? " dev" : ""} ${ctx.components?.length ?? 0} components`;
export default defineAdapter({
name: "demo",
extensions: [".demo"],
clientEntry: "@silgi/demo/runtime/client",
serverEntry: "@silgi/demo/runtime/server",
peers: [],
plugins: (ctx) => [{ name: label(ctx), setup() {} }],
});
export const path = (route: ResolvedRoute) => route.matched.map((record) => record.path).join(" ");
AdapterServerEntry, RenderContext, RenderResult, RenderTree
serverEntry exports render(ctx). RenderContext has url, route, tree, payload, head, app, stream (the adapter may return a ReadableStream as body when it is true), baseURL, app.baseURL with one trailing slash, which Link and useRouter() put app paths under, href(to), which returns the href of an app path (withBase) or of a route location (locationHref) under that base and throws for an unknown name or a missing required param, so Link resolves to without a route table, and nonce, the request's CSP nonce when the app set one, which the adapter writes as nonce="…" on every inline <script> its library emits. RenderTree.components lists the app root, the layout and the pages, outermost first, each with id, component and props; error is set when the error page is rendered. RenderResult is { body: string | ReadableStream<Uint8Array>; head?: string }.
import type { AdapterServerEntry, RenderContext, RenderResult, RenderTree } from "silgi/adapter";
const text = (tree: RenderTree) => tree.components.map((entry) => entry.id).join(" > ");
export const entry: AdapterServerEntry = {
async render(ctx: RenderContext): Promise<RenderResult> {
return { body: `<main>${text(ctx.tree)}</main>`, head: ctx.stream ? undefined : "" };
},
};
APP_ROOT_ID
"app:root", the id of the app/app.<ext> entry of RenderTree.components. The entry is first and only present when the app has that file; render it like a layout, with the rest of the tree as its child.
const APP_ROOT_ID: "app:root"
silgi/adapter/dev
The entry for dev-only helpers: OVERLAY_HOOK, OverlayHook, mergeMappings and MapPiece. It is separate from silgi/adapter so that no production client holds them.
OVERLAY_HOOK, OverlayHook
Import from silgi/adapter/dev, which only dev code and build plugins use, so no production client holds it. "__silgiOverlayHook", the globalThis key of the object the dev error overlay installs before any module script runs under silgi dev. Its failed turns true on the first uncaught error or unhandled rejection on the page, which the overlay takes from window before other listeners see it. A dev-only adapter runtime reads it to learn that an error happened, for example to reload after a failed hot update. It is absent from production pages.
const OVERLAY_HOOK: "__silgiOverlayHook"
interface OverlayHook {
errors: unknown[];
failed: boolean;
report?(error: unknown): void;
}
AdapterClientEntry, ClientContext
clientEntry exports hydrate, mount and update. ClientContext has the same fields as RenderContext without stream and nonce, plus navigator and root. root is the element with id app.rootId that the app renders into; the core looks it up, so an adapter never names the id itself. baseURL is app.baseURL with one trailing slash, as on the server, and is set again on every update; href resolves against the browser's route table as RenderContext.href does on the server. hydrate attaches to server HTML, mount renders without it, update follows a navigation.
import type { AdapterClientEntry, ClientContext } from "silgi/adapter";
const log = (name: string, ctx: ClientContext) => console.log(name, ctx.route.fullPath, ctx.tree.components.length);
export const entry: AdapterClientEntry = {
hydrate: (ctx) => log("hydrate", ctx),
mount: (ctx) => log("mount", ctx),
update: (ctx) => log("update", ctx),
};
setAdapterInstance
Sets app.adapter to the library's app instance and calls the app hook app:adapter(instance, app) once per app context and instance: the first time for that context, and again when a new non-null instance comes, as for Vue's error page on the server. null counts once per context. Await it in render, hydrate and mount before the first render, after the instance exists: Vue passes the App from createSSRApp or createApp before mount, the other adapters pass null. Do not call it in update. A handler that throws rejects the promise, so the render fails: the server answers the error page, the client start rejects.
function setAdapterInstance(app: object | undefined, instance: unknown): Promise<void>
export async function hydrate(ctx: ClientContext): Promise<void> {
const app = createSSRApp(Root);
await setAdapterInstance(ctx.app, app);
app.mount(ctx.root);
}
renderInPasses, RenderInPassesOptions
The server render loop for a library whose renderer cannot wait for data. It calls render(head, pass), and when that render started a pending entry that is not lazy (the composable called markWaiting), it drops the result (drop, if given), disposes the head entries that pass pushed, awaits app.asyncData.settled() and renders again, up to 8 passes. It returns the last render. Push into the head that render receives, not into ctx.head, so a dropped pass leaves no entries behind.
interface RenderInPassesOptions<T> {
app: object;
head: Head;
render(head: Head, pass: number): T | Promise<T>;
drop?(result: T): void | Promise<void>;
}
function renderInPasses<T>(options: RenderInPassesOptions<T>): Promise<T>
export const render = async (ctx: RenderContext) =>
renderInPasses<RenderResult>({
app: ctx.app,
head: ctx.head as Head,
render: (head) => ({ body: renderToString(buildElement({ ...ctx, head })) }),
});
markWaiting
Call it from useAsyncData and useFetch on the server, with app.asyncData, when the entry the component started is pending and not lazy. It marks the current render as waiting, so renderInPasses renders again after the store has settled.
function markWaiting(store: object): void
mergeMappings, MapPiece
Import from silgi/adapter/dev. For a build plugin that joins several compiled parts into one module for Bun, such as a script and a template. It merges the source maps of the parts into one mappings string over a single source file, for the inline map the plugin ends its contents with (//# sourceMappingURL=data:application/json;base64,…). In dev, the error overlay maps a position through that inline map to the original file, line and code frame. Each piece is a part's map (only its mappings is read) with the zero-based line of the module where the part starts and, when the part is cut out of the original file, the zero-based sourceLine and sourceColumn where it starts there.
interface MapPiece {
map: { mappings: string };
line: number;
sourceLine?: number;
sourceColumn?: number;
}
function mergeMappings(pieces: MapPiece[]): string
ResolvedRoute
A matched route: name, path, fullPath, params, query, search (the query after the chain's search schemas, equal to query when no page exports one), hash, meta (a PageMeta) and matched, a RouteMatch per page from the outermost to the current one.
RouteMatch
One entry of route.matched: the page's route name, its path pattern ("/blog/:slug") and its own meta export, not merged with its parents' ({} when it exports none). The server and the browser build the same entries, and an entry stays the same object between navigations. The page file and loader file that pages:extend sees never reach it.
interface RouteMatch {
name: string
path: string
meta: PageMeta
}
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). Other keys are yours. silgi/app exports the same type, which is where an app imports it from.
import type { PageMeta } from "silgi/adapter";
export const meta: PageMeta = {
layout: "plain",
middleware: ["auth"],
ssr: false,
};
App primitives for adapters
The stores and helpers that an adapter, the client entry and tests build an app with. App code does not call them, so they are not in silgi/app and auto-import does not offer them.
createAppContext, CreateAppContextOptions
Makes the app context (AppContext of silgi/app): one per request on the server, one per page load in the browser.
function createAppContext(options: CreateAppContextOptions): AppContext
interface CreateAppContextOptions { state: State; asyncData?: AsyncData; config: AppConfigs; head: Head; cookies?: CookieJar; fetch?: $Fetch }
import { defineAppConfig } from "silgi/app";
import { createAppContext, createHead, createState } from "silgi/adapter";
export const appConfig = defineAppConfig({ title: "Shop", theme: { primary: "#0a7" } });
export const app = createAppContext({
state: createState({ cart: [] }),
head: createHead(),
config: { public: { apiBase: "/api" }, app: appConfig },
});
app.hooks.hook("app:created", () => console.log("ready"));
runtimeConfigOf
What useRuntimeConfig() of every adapter returns (ClientRuntimeConfig of silgi/app). It builds the object once per app context and returns that object on every call.
function runtimeConfigOf(app: AppContext): ClientRuntimeConfig
publicError
The error a server-side ErrorBoundary may show and record for the client. On the server it is app.maskError(error): a SilgiError stays as it is, any other error is logged and becomes 500 Internal Server Error (or the foreign status and its status text) outside dev. In the browser, where the app context has no maskError, it returns the error unchanged.
function publicError(app: AppContext, error: unknown): unknown
reportClientError
A browser error that reached the adapter's root error hook. It always calls the app:error hook. With render: true (the library cannot show the tree past the error) it shows the error page; with render: false it shows it only for a SilgiError with fatal: true, otherwise the page stays. source is what app:error gets: "render" when render is true, else "hook".
function reportClientError(app: object, error: unknown, options: { render: boolean; source?: "render" | "navigation" | "plugin" | "hook" | "chunk" | "window" | "rejection" }): void
createState
Makes the keyed store State of silgi/app, optionally restored from the server's dump.
function createState(initial?: Record<string, unknown>): State
import { createState } from "silgi/adapter";
import type { State } from "silgi/app";
export const state: State = createState({ count: 1 });
const stop = state.subscribe("count", (value) => console.log("count", value));
state.set("count", 2);
console.log(state.use("theme", "dark"), state.has("theme"), state.get<number>("count"));
console.log(state.dump());
stop();
createAsyncData, CreateAsyncDataOptions
Makes the keyed store behind useAsyncData and useFetch (AsyncData of silgi/app). CreateAsyncDataOptions is { ssr?: boolean; initial?; mask?; redirect? }: ssr makes server: false skip the handler and turns off the purge, initial restores a server render's entries, mask turns the error of a failed run into the one the entry holds (the server passes its error masking), and redirect checks a thrown redirect before the entry holds it (the server passes its target checks; a throw there is the run's error).
function createAsyncData(options?: CreateAsyncDataOptions): AsyncData
import { clearAsyncData } from "silgi/app";
import { createAsyncData } from "silgi/adapter";
const store = createAsyncData({ ssr: true });
const entry = store.fetch("user", async () => ({ name: "Ada" }), { default: () => ({ name: "" }) });
console.log(entry.status);
await store.settled();
console.log(store.get<{ name: string }>("user")?.data?.name);
console.log(store.dump());
await store.refresh("user");
clearAsyncData({ asyncData: store }, "user");
asyncDataKey, isHandlerReference, HandlerReference
The key of useAsyncData(fn, input): $fn:<handler id>:<functionInputHash(input)>, the same hash that names the prerendered file of that input. An input the serializer refuses throws its TypeError at the call. isHandlerReference(value) is true for a client stub and for the handler on the server, so a handler and a getter key are told apart without reading length. Neither is for app code: a user names an entry with (fn, input).
function asyncDataKey(fn: HandlerReference, input?: unknown): string
function isHandlerReference(value: unknown): boolean
stateArguments, asyncDataArguments
Read the arguments of useState and of useAsyncData / useLazyAsyncData, including the key the build appends to a call that has none (build.keyedComposables). Every adapter calls them first and passes its own (...args) through, so keying is not written again in an adapter. A branded last argument, { [Symbol.for("silgi.key")]: "$s<8 hex>" }, is dropped from the arguments and is the key only when the call gave none: useState(init) and useAsyncData(handler, options?). A call whose first argument is a handler (isHandlerReference) takes no key from it. A call still without a key throws useState() needs a key; pass one or list the function in build.keyedComposables (useAsyncData() and useLazyAsyncData() for those). asyncDataArguments(name, args, isKey?) returns [first, second, options] as (key | getter | handler, handler | input, options); isKey names a key of the library besides a string and a getter (@silgi/vue passes isRef).
function stateArguments(args: readonly unknown[]): { key: string; init: unknown }
function asyncDataArguments(name: string, args: readonly unknown[], isKey?: (value: unknown) => boolean): [first: unknown, second: unknown, options: unknown]
fetchKey
The store key of a useFetch call: $fetch:<METHOD>:<url> and, when other options identify the request, :<stable JSON> of them. The cookie and authorization request headers are left out, because the key is written into the page payload and the request-bound $fetch forwards them itself; every adapter builds its keys with it.
function fetchKey(url: string, options?: FetchOptions): string
resolveCallOptions, mergeCallOptions, isPending
What createUseFetch, createUseAsyncData and pending of every adapter are built on. mergeCallOptions(defaults, options, read?) puts a call's options over the defaults: a value of the call wins unless it is undefined, headers merge by name (the call's header replaces a default of the same name) and query by key. Objects that come from the defaults are copied, so a call that changes them never changes the defaults of the next; the call's own objects are passed on as they are, so a reactive query keeps tracking. read turns a query that is a getter or a ref into its value; such a query makes the merged one a getter. resolveCallOptions(defaults, options, read?) is mergeCallOptions for an object and, for a function, the result of calling it with a copy of the call's options. isPending(app, entry) is entry.status === "pending", or an idle entry with no data under app.asyncData.pendingWhenIdle.
function mergeCallOptions<D extends object, O extends object = D>(defaults: D, options: O, read?: (value: unknown) => unknown): D & O
function resolveCallOptions<O extends object>(defaults: O | ((options: O) => O), options: O | undefined, read?: (value: unknown) => unknown): O
function isPending(app: Pick<AppContext, "config">, entry: Pick<AsyncDataEntry, "status" | "data">): boolean
matchesOf, routeDataOf, MatchedRoute, RouteDataName, RouteDataOf
What useMatches() and useRouteData() of every adapter are built on. matchesOf(route, data) is route.matched with each entry's params (the route params its path names) and data (data[name] when the payload holds it); routeDataOf(route, data, name) is data[name] when name is in route.matched, else undefined, so a route outside the chain gives nothing whatever data holds. data is the data of the render's payload. RouteDataName is the route names of SilgiLoaders once silgi typegen wrote them, else string; RouteDataOf<N> is LoaderData<N> | undefined for a name of SilgiLoaders, else unknown.
function matchesOf(route: Pick<ResolvedRoute, "matched" | "params">, data: Record<string, unknown> | undefined): MatchedRoute[]
function routeDataOf(route: Pick<ResolvedRoute, "matched">, data: Record<string, unknown> | undefined, name: string): unknown
interface MatchedRoute extends RouteMatch { params: ResolvedRoute["params"]; data: any }
RouteName, RouteFor
What useRoute(name) of every adapter is typed with. RouteName is the route names of SilgiRoutes once silgi typegen wrote them, else string; RouteFor<N> is RouteValue for never, else the route with params from SilgiRoutes[N], search from SilgiSearch[N] and name any RouteName. RouteFor distributes over a union N.
type RouteName = [keyof SilgiRoutes] extends [never] ? string : keyof SilgiRoutes & string
type RouteFor<N extends string> = [N] extends [never] ? RouteValue : RouteOf<N, RouteName>
RouteError
What useError() of every adapter is typed with. While no page exports Errors it is ErrorPageProps. Once one does, it is a union discriminated by route and status: for each route of SilgiErrors, one member per declared status, { route; status; data?: Serialized<Errors[status]> }, and one for its other statuses from 400 to 599 with data?: unknown; and one plain member, route any other route name or absent, data?: unknown. So if (error?.route === "blog-slug" && error.status === 404) error.data?.slug compiles, and a check of any other route or status still does. data stays optional: an error of a declared status may carry none (a bare createError({ status: 404 }), showError). Only the routes that declare Errors enter the union.
type RouteError = [keyof SilgiErrors] extends [never] ? ErrorPageProps : { [N in keyof SilgiErrors & string]: PageError<N, SilgiErrors[N]> }[keyof SilgiErrors & string] | (Omit<ErrorPageProps, "status" | "data" | "route"> & { route?: Exclude<RouteName, keyof SilgiErrors>; status: number; data?: unknown })
routeStateOf
What status, error, renderMode, state, key and navigationType of useRoute() are built from. routeStateOf({ route, app, payload, tree }) gives them for one render; state, key and navigationType are those of route, which the navigator sets from the history entry on screen (03 §9.5), else {}, "" and "initial". Of the others: status is the status of tree.error when an error page is shown, else the number in payload.status, else 200; error is the value useError() gives: the app's error store when it holds one, else tree.error.toJSON(); renderMode is "prerendered" when payload.prerendered is true, else the mode of the page load, which it remembers per app: "spa" when the first payload it saw had serverRendered: false, "ssr" otherwise. The server sets payload.status when it is not 200 and payload.prerendered for a page the prerender requested; a client navigation takes the status a handler set during its load (the last one other than 200), and a static build writes it into _payload.json. Every adapter calls it once per render and per navigation.
function routeStateOf(source: { route?: Pick<ResolvedRoute, "state" | "key" | "navigationType">; app?: object; payload?: Record<string, unknown>; tree?: { error?: SilgiError } }): RouteState
snapshotResponse
Returns a function that puts the response status, status text and headers of app.event back as they are when snapshotResponse is called. An adapter that drops a render pass and renders again calls it before each pass and the function when it drops the pass, so a status or header written by the dropped pass does not reach the answer. renderInPasses does this itself.
function snapshotResponse(app: object): () => void
createHead, CreateHeadOptions
Makes the head manager (Head of silgi/app). With document, apply() brings the real document in line with the entries; without it, apply has nothing to change.
function createHead(options?: CreateHeadOptions): Head
interface CreateHeadOptions { document?: HeadDocument }
import { SEO_META_KEYS, createHead, seoMetaToHead } from "silgi/adapter";
const head = createHead();
const entry = head.push({ title: "Home", titleTemplate: "%s · Shop", meta: [{ name: "description", content: "Welcome" }] });
head.push(seoMetaToHead({ ogTitle: "Shop", twitterCard: "summary" }));
entry.patch({ title: "Welcome" });
export const html = head.render();
console.log(html.head.join("\n"), Object.keys(SEO_META_KEYS).length);
entry.dispose();
import { createHead } from "silgi/adapter";
import type { HeadDocument } from "silgi/app";
export function startHead(document: HeadDocument) {
const head = createHead({ document });
head.push({ htmlAttrs: { lang: "en" } });
head.apply();
return head;
}
HEAD_MARK
"data-silgi-head", the attribute every head tag Silgi writes carries, so the browser can find and replace them.
seoMetaToHead, SEO_META_KEYS
seoMetaToHead turns the flat keys of useSeoMeta (SeoMetaInput of silgi/app) into a HeadInput. SEO_META_KEYS maps each flat key to the [attribute, name] of its meta tag, such as ogTitle to ["property", "og:title"].
function seoMetaToHead(input: SeoMetaInput): HeadInput
import { seoMetaToHead } from "silgi/adapter";
import type { SeoMetaInput } from "silgi/app";
const input: SeoMetaInput = { title: "Pricing", description: "Plans and prices", ogImage: "/og.png", twitterCard: "summary_large_image" };
export const head = seoMetaToHead(input);
prefetchAttribute, PrefetchOn, PrefetchProps, PrefetchOptions, PrefetchResult
The rule behind the prefetch props of Link: prefetchAttribute({ prefetch, prefetchOn, noPrefetch }) returns the data-silgi-prefetch value. prefetch: false or noPrefetch gives none; otherwise prefetchOn (default "interaction") gives hover, visible or hover visible, and an object with neither flag true gives none. The old strings "hover", "visible" and "none" as prefetch are returned as they are, with one dev warning.
type PrefetchOn = "visibility" | "interaction" | { visibility?: boolean; interaction?: boolean }
function prefetchAttribute(props: { prefetch?: boolean; prefetchOn?: PrefetchOn; noPrefetch?: boolean }): string
PrefetchOptions is { data?: boolean } and PrefetchResult what prefetch(to, options) of the navigator and of RouterApi resolves to. prefetch(to, { data? }) resolves, never rejects, to { type: "loaded", data } (a load ran; data is the data of the load files that ran for this prefetch, so a layout or parent route the screen keeps is not in it), { type: "redirect", location } or { type: "error", error } (a load redirected or threw), { type: "chunks" } (the chunks are loaded and no load ran: data: false, no load on the route, or a static build whose payload file says to reload) or { type: "skipped" } (a target on another origin, no page, a malformed path, a route name or params that match no page, or a prefetch cancelled by a navigation to the same page); a chunk that fails to load gives error when no load ran. A prefetch of a URL a navigation is already loading joins that load and runs none. The load result is held 30 seconds and the next navigate() to the same URL uses it once and runs no second load; a redirect or error result is not held. data: false loads the chunks only and calls no load and no handler.
type PrefetchResult =
| { type: "loaded"; data: Record<string, unknown> }
| { type: "redirect"; location: string }
| { type: "error"; error: SilgiError }
| { type: "chunks" }
| { type: "skipped" }
linkState, LinkState
The rule behind the active classes of Link: linkState(to, route) returns { active, exact } for the app path to and a route (path, params, matched). exact is true when the pathname of to equals route.path; active is true then, or when it equals the path of a record in route.matched other than the last, filled with route.params. Query, hash, a trailing / and percent-encoding do not matter. A to that does not start with a single / is neither.
function linkState(to: string, route: Pick<ResolvedRoute, "path" | "params" | "matched">): { active: boolean; exact: boolean }
setLinkState
Keeps the state of a Link for its anchor element, in memory: setLinkState(anchor, state) when the anchor mounts or its state changes, setLinkState(anchor, undefined) to drop it. A click the navigator intercepts on that element stores the state with the history entry it pushes, as navigate(to, { state }) does. Nothing is read from the DOM, so an anchor in user HTML cannot set a state. A state that is not payload-serializable (04 §4) and structured-cloneable, or longer than 65 536 characters serialized, is logged and the click navigates without it.
function setLinkState(element: object, state: Record<string, unknown> | undefined): void
pendingRuns, optimisticSource, OptimisticSource
pendingRuns(app) lists the running PendingRuns of an app, oldest first; the array is the same until a run starts or ends. optimisticSource(app) is what useOptimistic reads: a store over pendingRuns, whose get() returns the same array until the list changed.
function pendingRuns(app: object): readonly PendingRun[]
function optimisticSource(app: object | null | undefined): OptimisticSource
interface OptimisticSource { get(): readonly OptimisticEntry[]; subscribe(listener: () => void): () => void }
runFunctionForm, linkActionRunner
<Form action={fn}> with JavaScript: runFunctionForm(app, fn, input, formData, refresh, form?, options?) calls the handler through its stub with input (the form's fields as formToObject makes them), one call after another per function and app, and refreshes the page as useAction does. The value lands in state, or the error in error, of the runners of the useAction(fn) called below that form (form.owner, which useAction finds through the form's context), else of the runners outside any form, linked with linkActionRunner(app, fn.id, runner, instance?) (the returned function unlinks). options.instance keys the post by (id, instance): its queue and its result stay with that instance, and only the runners linked with the same instance get it, through form.owner.link(fn.id, runner, instance) below a form. The runners are taken when the post starts: one that was unlinked, mounted later or reset() since does not get the result (ActionRunner.epoch() and adopt(result, epoch)), and a result with no runner goes nowhere but the form's own callbacks. form.track hears the post, which is how useFormStatus is pending for it. It resolves true after a success, "invalid" after a fail() or an error whose data holds issues, and false after any other end, a redirect included (the stub follows the redirect). ActionRunner.adopt leaves pending and the queue alone.
function runFunctionForm<I, O>(app: object, fn: Handler<I, O>, input: I, formData: FormData, refresh: () => Promise<void>, form?: { owner; track(done: Promise<unknown>, action: string): void }, options?: { refresh?: boolean; instance?: string | number }): Promise<boolean | "invalid">
function linkActionRunner(app: object, id: string, runner: ActionRunner<any, any>, instance?: string | number): () => void
restoredResult, RestoredResult
restoredResult(state, id, instance?) reads payload.handlerState, the result of a post without JavaScript to a handler, for the handler id and instance: { state } for { id, value }, { error } (a SilgiError) for { id, error }, { failure } (an ActionFailure) for { id, failure }, undefined for another function, another instance or no state. Pass it as restored of createActionRunner, which then starts from it; useAction does.
function restoredResult<O>(state: unknown, id: string, instance?: string | number): RestoredResult<O> | undefined
interface RestoredResult<O> { state?: O; error?: SilgiError; failure?: ActionFailure }
functionFormAction
The action of Form action={fn} without JavaScript: functionFormAction(fn, page, instance?) adds _fn=<id> to page, a path with its base and query, with :<instance> after the id, and drops any hash. The post runs the handler and renders the page in one answer. A GET handler throws TypeError: Form action takes a POST handler; <label> is GET.
function functionFormAction(fn: Pick<Handler<never, unknown>, "id" | "method" | "label">, page: string, instance?: string | number): string
assertFormMethod
The one check of every adapter's Form: assertFormMethod(action, method) throws TypeError: Form posts through a handler: Form action={fn}; use a plain <form> for another URL when action is not a function and method is anything but get.
function assertFormMethod(action: unknown, method: string | undefined): void
createActionRunner, ActionRunner, ActionRunnerConfig, ActionOptions, ActionSnapshot, Handler
What useAction is made of: createActionRunner(fn, { options, refresh }) runs the handler fn one call after another and keeps { state, pending, error, failure }. With app, each call is listed in pendingRuns(app) under instance, and post is run with the entry to list. A fail() lands in failure, with no onSuccess and no refresh, and the next run clears it. options() returns the ActionOptions (initial, onSuccess, onError, refresh) and is read on every call, so an adapter hands in new callbacks without a new runner; refresh() is router.refresh() and runs after each success unless refresh is false. run(input) queues a call and returns its value, get() returns the same ActionSnapshot until something changed, subscribe(listener) hears each change, and reset() aborts the running call, drops the waiting ones and returns to initial: none of them commits. pending is true from run until the call and its refresh ended and the calls behind it did. A redirect rejects run and is no error. Handler<I, O> is the type of a function and of its client stub (silgi/server exports it for server code).
function createActionRunner<I, O>(fn: Handler<I, O>, config: { options(): ActionOptions<O>; refresh(): Promise<void>; restored?: RestoredResult<O>; app?: object | null; instance?: string | number }): ActionRunner<I, O>
interface ActionRunner<I, O> { run(input: I): Promise<O>; post(input: I, run: PendingRun): Promise<O>; get(): ActionSnapshot<O>; subscribe(listener: () => void): () => void; reset(): void }
interface ActionSnapshot<O> { state: O | undefined; pending: boolean; error: SilgiError | undefined; failure: ActionFailure | undefined }
interface ActionOptions<O> { initial?: O; onSuccess?(value: O): void; onError?(error: SilgiError): void; refresh?: boolean }
createFormStatus, FormStatus, FormStatusStore, IDLE_FORM_STATUS
What useFormStatus reads: createFormStatus() makes the store of one Form. The Form passes the FormData of each submit it starts to submitted(formData) (from the onSubmit it registers with enhance). runFunctionForm reports the post with track(done, action), and get() is { pending: true, data, action }, with the form's data and the handler's url as action, until it settles, and IDLE_FORM_STATUS otherwise. get() returns the same object until the status changed. owner is where the useAction runners called below the form link themselves (owner.link(id, runner), owner.runners(id)). subscribe hears the tracked posts.
function createFormStatus(): FormStatusStore
interface FormStatus { pending: boolean; data: FormData | null; action: string | null }
interface FormStatusStore { get(): FormStatus; subscribe(listener: () => void): () => void; submitted(formData: FormData): void; track(done: Promise<unknown>, action: string): void; readonly owner: { link(id: string, runner: ActionRunner<any, any>): () => void; runners(id: string): ActionRunner<any, any>[] } }
const IDLE_FORM_STATUS: FormStatus
locationHref
Builds the URL of a named route. Params are encoded, **:name takes an array, query and hash are appended, and the result starts with baseURL. A missing required param or an unknown name throws.
function locationHref(routes: NamedRoute[], to: RouteLocation, baseURL?: string): string
NamedRoute is { name: string; path: string; children: NamedRoute[] }.
import { locationHref } from "silgi/adapter";
import type { RouteLocation } from "silgi/app";
const routes = [{ name: "blog-slug", path: "/blog/:slug", children: [] }];
const to: RouteLocation = { name: "blog-slug", params: { slug: "hello world" }, query: { ref: "home" }, hash: "top" };
export const href = locationHref(routes, to, "/shop/");
CheckedTarget
CheckedTarget<T, External = true, Http = false> is the type a string target T of Link, navigate or redirect is checked with (06 §10.3): T itself when it may be given (any string before typegen, a string, a relative path, a path some entry of SilgiRoutePaths can match or one with a % escape and, unless External is false, //host or a URL with a scheme app.linkProtocols lists, only http(s):// with Http). Else the union of the app's path patterns for a path, and for any other target a string type that names the cause ("<T> needs { external: true }", "<T> has a scheme app.linkProtocols does not list"), so the call fails on its line. An adapter's Link takes to: CheckedTarget<T> | RouteLocation (LinkTarget<T>) with T extends string as a type parameter of the component.
type CheckedTarget<T extends string, External extends boolean = true, Http extends boolean = false>
rejectBlockedTarget
What useRouter().navigate() and replace() check before they put app.baseURL in front of a string target. It returns a rejected promise holding the SilgiError 400 that navigate() throws (03 §9, C-262) when the scheme of to is not in app.linkProtocols, so history.pushState is never called, and undefined for a path, an allowed scheme or a named route.
function rejectBlockedTarget(to: unknown): Promise<never> | undefined
subscribeAppConfig, liveAppConfig
What an adapter's useAppConfig() is built from. subscribeAppConfig(app, listener) calls listener after every updateAppConfig of app and returns the unsubscribe function. liveAppConfig(app, track) returns the app config as an object that calls track before every read, so the adapter's own reactive primitive re-runs the readers after an update.
function subscribeAppConfig(app: AppContext, listener: () => void): () => void
function liveAppConfig<T extends object>(app: AppContext, track: () => void): T
startPoll, pollInterval
What an adapter's usePoll(ms, { keys? }) is built from. pollInterval(ms) returns ms when it is at least 1000 and otherwise throws RangeError: usePoll() needs at least 1000 ms (got 200); call it where the hook is called, so the error is the component's. startPoll(ms, run) checks ms the same way, then calls run every ms while the document is visible and the browser online (hiding it stops the timer, showing it starts a new one; a tick while offline is skipped; a refresh a tick starts keeps the page when the network fails) and returns the stop function, which the adapter calls on unmount; run is () => router.refresh({ keys }). Without a document (the server) it does nothing.
function pollInterval(ms: number): number
function startPoll(ms: number, run: () => unknown): () => void
isOnline, subscribeOnline
What an adapter's useOnline() is built from. isOnline() is navigator.onLine, and true on the server or where the browser does not say. subscribeOnline(listener) calls listener(true) on online and listener(false) on offline and returns the unsubscribe function. useOnline() is true on the server and during hydration, so the adapter reads isOnline() only once mounted.
function isOnline(): boolean
function subscribeOnline(listener: (online: boolean) => void): () => void
searchTarget, uncoveredQuery, equalSearch, SearchUpdate, SetSearch
What an adapter's Link query and useSearch are built from. searchTarget(to, route, update) returns to with the query update(route.search) makes plus uncoveredQuery(route) (the raw query keys of route that no search schema outputs, such as a layout's searchKeys), flat-encoded and untrimmed (arrays as repeated keys, undefined and null left out): for a string target the path and hash are kept and its old query is replaced, for a { name, … } location query is set. The adapter's Link calls it with the route's search and marks the anchor data-silgi-trim; the navigator leaves out the keys at their default when that anchor is clicked. equalSearch(a, b) is the deep equality useSearch(select) uses to keep the previous selection. SearchUpdate is { query: (prev) => next, hash? }, the extra target RouterApi.navigate, push and replace take; SetSearch is the type of the set that useSearch returns.
function searchTarget(to: string | RouteLocation, route: { search: Record<string, unknown>; query?: Record<string, string | string[]> }, update: (prev: never) => Record<string, unknown>): string | RouteLocation
function equalSearch(a: unknown, b: unknown): boolean
setNavigateTarget, NavigateTarget
What navigateTo navigates with in the browser. The client entry sets it before the first navigation: the navigator, ClientContext.href and the page origin. With none set (the server) navigateTo throws a redirect. Adapters do not call it; a test may.
function setNavigateTarget(target: NavigateTarget | undefined): void
interface NavigateTarget { navigator: Pick<Navigator, "navigate" | "replace">; href(to: string | RouteLocation): string; origin: string }
createRouterApi, RouterSource
Builds the RouterApi an adapter's useRouter() returns (03 §9.1), so no adapter writes its own. In the browser navigator is ClientContext.navigator: a string target of navigate, push, replace, prefetch and resolve gets baseURL in front (a blocked scheme rejects first, as rejectBlockedTarget does), and a beforeEach guard's string answer gets it too. On the server, with no navigator, resolve, getRoutes and hasRoute read the route table href was built from (RenderContext.href), currentRoute is route, beforeEach, afterEach and onError register nothing, go does nothing, isReady() and refresh resolve, prefetch resolves to { type: "skipped" }, and navigate, push, replace, back and forward throw unavailable(name), by default `${name}() is only available in the browser`. A partial navigator (a test double) falls back to the server behaviour for what it lacks.
function createRouterApi(source: RouterSource): RouterApi
interface RouterSource {
navigator?: unknown;
baseURL?: string;
href?: (to: string | RouteLocation) => string;
route?: ResolvedRoute;
url?: URL;
unavailable?: (name: string) => Error;
}
navigationHook, NavigationHookKind
Registers fn on the navigator of the page (03 §9.2) and returns its remover; with no navigator (the server, or before start) it registers nothing and returns a function that does nothing. An adapter's onBeforeNavigate, onNavigate and onAfterNavigate call it when the component mounts and call the remover when it unmounts.
type NavigationHookKind = "before" | "navigate" | "after"
function navigationHook(kind: "before", fn: BeforeNavigateHook): () => void
function navigationHook(kind: "navigate", fn: NavigateHook): () => void
function navigationHook(kind: "after", fn: AfterNavigateHook): () => void
routeGuard, RouteGuardKind
Registers fn as the leave or update guard (03 §9.3, step 3) of the tree entry entry, the id of RenderTree.components the calling component renders under, and returns its remover. With entry undefined the guard belongs to the leaf page on screen when it registers. With no navigator (the server, or before start) it registers nothing. An adapter's onBeforeRouteLeave and onBeforeRouteUpdate call it when the component mounts and call the remover when it unmounts.
type RouteGuardKind = "leave" | "update"
function routeGuard(kind: RouteGuardKind, entry: string | undefined, fn: NavigationGuard): () => void
navigationBlocker, NavigationBlocker, BlockerArgs
Registers fn as a blocker on the navigator of the page (03 §9.4) and returns its remover; with no navigator it registers nothing. fn is asked after onBeforeNavigate of every navigation but a form's, in registration order: true blocks it, false lets it go, a promise is awaited (a back or forward is undone with history.go while it waits and repeated once all blockers let it go). An adapter's usePreventNavigate calls it.
interface BlockerArgs { from: ResolvedRoute; to: ResolvedRoute | null; type: NavigationType }
type NavigationBlocker = (args: BlockerArgs) => boolean | Promise<boolean>
function navigationBlocker(fn: NavigationBlocker): () => void
createBlocker, Blocker, BlockerStore, ShouldBlock, UseBlockerOptions
The store behind every adapter's useBlocker (03 §9.4). start() registers its blocker with navigationBlocker and, unless beforeUnload: false, a beforeunload listener that calls preventDefault(), present only while shouldBlock({ from, to: null, type: "leave" }) is true and skipped for an unload the navigator let go or a form submission's; it returns the remover, which also answers a blocked navigation true. sync() asks shouldBlock for leave again and adds or removes that listener; the adapter calls it after each render or in an effect, and the store calls it on document input and change and after each navigation. A blocked navigation that another one supersedes turns the store unblocked. get() is the current Blocker, a new object on every change, which subscribe reports. setOptions replaces shouldBlock and beforeUnload for the next navigation (React and Preact call it on each render).
type ShouldBlock = (args: BlockerArgs) => boolean
interface UseBlockerOptions { shouldBlock: ShouldBlock; beforeUnload?: boolean }
interface Blocker { state: "unblocked" | "blocked" | "proceeding"; location?: ResolvedRoute; proceed(): void; reset(): void }
interface BlockerStore {
get(): Blocker;
subscribe(listener: () => void): () => void;
setOptions(options: ShouldBlock | UseBlockerOptions): void;
sync(): void;
start(): () => void;
}
function createBlocker(options: ShouldBlock | UseBlockerOptions): BlockerStore
registerSnapshot, SnapshotOptions
What every adapter's useSnapshot calls (03 §9.6): registers { key, capture, restore } and returns its remover; with no navigator it registers nothing. The first registration watches the page's navigator: before a navigation commits, every live capture() is kept (as a payload-serialized copy) under the history key of the page it leaves; after a popstate navigation or the first page of a reload (enter), each live restore whose key that entry holds gets its value, and a key that registers later, before the next navigation, gets it when it registers. A value over 65 536 characters serialized is logged and not kept, and a cookie change drops every value. On pagehide the entry on screen is captured and the map is written to sessionStorage item silgi:snapshot (at most 100 history keys, the oldest dropped).
interface SnapshotOptions<T> { capture: () => T; restore: (value: T) => void }
function registerSnapshot<T>(key: string, options: SnapshotOptions<T>): () => void
Adapter API types
The types that app code names and that every adapter re-exports under the same name (import type { RouterApi } from "@silgi/react"). They are defined here once, so an adapter imports them and never declares its own.
RouteValue, RouterApi, NavigateOptions, NavigationState, GetCachedData, GetCachedDataCause, AsyncDataHandler, FetchRequest, ErrorBoundaryFallbackProps, RouteLoaderData, UseDataArgs, RouteState, RenderMode
type RouteValue = {
path: string;
fullPath: string;
params: ResolvedRoute["params"];
query: ResolvedRoute["query"];
hash: string;
name: string;
meta: ResolvedRoute["meta"];
matched: RouteMatch[];
status: number;
error: ErrorPageProps | undefined;
renderMode: RenderMode;
state: Record<string, unknown>;
key: string;
navigationType: "initial" | "push" | "replace" | "pop";
}
type RenderMode = "ssr" | "spa" | "prerendered"
type RouteState = Pick<RouteValue, "status" | "error" | "renderMode" | "state" | "key" | "navigationType">
type RouteLoaderData<T> = T extends keyof SilgiLoaders ? LoaderData<T> : T
type UseDataArgs<T> = [] | [never]
interface NavigateOptions { replace?: boolean; scroll?: boolean; external?: boolean; document?: true; shallow?: boolean; state?: Record<string, unknown>; refreshAll?: boolean; refresh?: string[]; keepFocus?: boolean }
type RouterApi = Pick<
Navigator,
"navigate" | "push" | "replace" | "back" | "forward" | "go" | "prefetch" | "refresh" | "resolve" | "beforeEach" | "afterEach" | "onError" | "isReady" | "getRoutes" | "hasRoute" | "canGoBack" | "pushState" | "replaceState"
> & {
readonly revalidation: "idle" | "loading";
readonly currentRoute: ResolvedRoute;
}
interface NavigationState {
state: "idle" | "loading" | "submitting";
to?: string;
formMethod?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
formAction?: string;
formData?: FormData;
formEncType?: "application/x-www-form-urlencoded" | "multipart/form-data" | "application/json";
json?: unknown;
revalidation?: "idle" | "loading";
type?: NavigationType;
from?: ResolvedRoute;
complete?: Promise<void>;
}
type GetCachedDataCause = "initial" | "refresh:hook" | "refresh:manual" | "watch"
type GetCachedData<T = unknown> = (key: string, app: AppContext, ctx: { cause: GetCachedDataCause }) => T | undefined
type AsyncDataHandler<R> = (ctx: { signal: AbortSignal }) => R | Promise<R>
interface FetchRequest extends Omit<FetchOptions, "query" | "body"> {
query?: FetchOptions["query"] | (() => FetchOptions["query"]);
body?: unknown;
}
interface ErrorBoundaryFallbackProps { error: unknown; reset: () => void }
RouterApi has the signatures of the navigator: navigate(to, options?), its alias push, replace(to, options?), refresh() and isReady() return Promise<void>, prefetch(to, options?) returns Promise<PrefetchResult>, and back(), forward() and go(delta) return void; resolve(to) returns a ResolvedLocation (below); beforeEach, afterEach and onError return their remover; getRoutes() returns RouteListEntry[] and hasRoute(name) a boolean; revalidation and currentRoute are read when you access them. RouteValue is the fields of ResolvedRoute; once silgi typegen wrote SilgiRoutes and SilgiSearch, name is the union of the route names and params and search are the route's types for a narrowed name (if (route.name === "blog-slug") route.params.slug), string and the record of ResolvedRoute before. RouteLoaderData<T> is the loader data when T is a key of SilgiLoaders, else T; UseDataArgs<T> is [], except that after typegen a string that is no loader name makes useData<"nope">() a type error. The wrappers a library needs, such as Accessor<…> or Ref<…>, stay in the adapter.
Navigation, NavigationType, BeforeNavigateHook, NavigateHook, AfterNavigateHook
One client navigation as the navigation hooks see it (03 §9.2). type is "enter" for the first page of the document, "link" for an intercepted <a> click, "goto" for navigate, push, replace, navigateTo and every redirect, "popstate" for back and forward, "form" for a GET form and a submission's navigation, and "leave" for the document unloading. from is null for enter; to is null for leave and for a target that is no page (a full load), and is known once the target's route is resolved. delta is set for popstate. willUnload is true for leave and for a navigation that ends in a full load. complete resolves after the commit and rejects with a NavigationFailure (cancelled or superseded) or the error shown; a full load leaves it pending. cancel() works only inside onBeforeNavigate: the URL and the page stay, a back or forward is undone, and for leave the browser asks the user to stay.
type NavigationType = "enter" | "link" | "goto" | "popstate" | "form" | "leave"
interface Navigation { type: NavigationType; from: ResolvedRoute | null; to: ResolvedRoute | null; delta?: number; willUnload: boolean; complete: Promise<void>; cancel(): void }
type BeforeNavigateHook = (navigation: Navigation) => void
type NavigateHook = (navigation: Navigation) => void | Promise<void>
type AfterNavigateHook = (navigation: Navigation) => void
ResolvedLocation, NavigationGuard, NavigationGuardResult, NavigationFailure, AfterEachHook, RouterErrorHandler, RouteListEntry
The types of the router calls of 03 §9.1. resolve(to) gives a ResolvedLocation: href with app.baseURL, path without it, name and params of the page it matches (none and {} when it matches no page), query and hash; an unknown route name or a missing param throws as locationHref does. A guard returns false to cancel, an app path or a RouteLocation to redirect (through the checks of a middleware redirect, so a loop ends on the error page with Redirect loop: "<to>" was reached twice in one navigation), anything else to go on; a throw shows the error page. afterEach runs once per navigation that reached its route: after the commit with no failure, or with { type: "cancelled" } when a middleware or guard cancelled it, or { type: "superseded" } when a newer navigation, submission or a back/forward started first. onError gets each error a navigation shows or reloads for, with the target route once it is known.
interface ResolvedLocation { href: string; path: string; name?: string; params: Record<string, string | string[]>; query: Record<string, string | string[]>; hash: string }
type NavigationGuardResult = void | boolean | string | RouteLocation
type NavigationGuard = (to: ResolvedRoute, from: ResolvedRoute) => NavigationGuardResult | Promise<NavigationGuardResult>
interface NavigationFailure { type: "cancelled" | "superseded" }
type AfterEachHook = (to: ResolvedRoute, from: ResolvedRoute, failure?: NavigationFailure) => void
type RouterErrorHandler = (error: SilgiError, to: ResolvedRoute | undefined) => void
interface RouteListEntry { name: string; path: string }