silgi/kit

The module author's tools. A module's setup runs while the build is configured, and the functions here act on that build. They also work inside the build hooks; from build:before on, the ones that add pages, layouts, route middleware, app plugins, a server door, templates, type templates or imports throw, naming the function and the hook. Calling one outside setup and the hooks throws, except createResolver, useLogger, logger and defineModule, which work anywhere. The guide is Modules.

defineModule

Returns the module unchanged and types its options.

function defineModule<T extends Record<string, any>>(module: SilgiModule<T>): SilgiModule<T>
import { defineModule } from "silgi/kit";
import type { SilgiModule, ModuleMeta } from "silgi/kit";

const meta: ModuleMeta = { name: "analytics", configKey: "analytics" };

const analytics: SilgiModule<{ id: string }> = {
  meta,
  defaults: { id: "" },
  setup(options, silgi) {
    console.log(`analytics ${options.id} in ${silgi.options.rootDir}`);
  },
};

export default defineModule(analytics);

SilgiModule, ModuleMeta

SilgiModule<T> has meta, defaults, hooks and setup(options, silgi). setup receives the options merged in this order, first wins: the inline options of [module, options] in modules, then config[meta.configKey], then defaults. ModuleMeta has name, so a module listed twice runs once, and configKey, the config key that holds the module's options; it defaults to name, and hooks, the names of the hooks the module calls and declares on SilgiHooks, so a config hooks entry naming one is not reported as an unknown hook. defaults is an object, or a function (silgi) => options called once before the merge. hooks maps hook names to a function or an array of functions, registered just before setup (also for a module without setup) and not at all for a module skipped as a duplicate. A module file may also default-export a plain setup function.

SilgiModuleOptions

The interface a module augments to type its options under its configKey, as shown on the silgi page. It is the same type that silgi exports.

createResolver, Resolver

createResolver(base) takes a directory, a file path or import.meta.url. resolve(...paths) joins paths onto that directory; resolvePath(id) resolves a module id the way Bun does from there.

function createResolver(base: string): Resolver
interface Resolver { resolve(...paths: string[]): string; resolvePath(id: string): string }
import { createResolver } from "silgi/kit";
import type { Resolver } from "silgi/kit";

const resolver: Resolver = createResolver(import.meta.url);

export const handler = resolver.resolve("./runtime/handler.ts");
export const entry = resolver.resolvePath("silgi");

useSilgi, SilgiContext

Returns the build context of the module that is running setup: options is the resolved config and hooks the build hooks. It is also the second argument of setup.

function useSilgi(): SilgiContext

SilgiContext is the type of that value, for a helper that takes the context as an argument.

import { defineModule, useLogger, useSilgi } from "silgi/kit";
import type { Logger } from "silgi/kit";

const log: Logger = useLogger("my-module");

export default defineModule({
  setup() {
    log.info("preset chosen", { preset: useSilgi().options.preset });
    log.warn("this module is experimental");
  },
});

useLogger, Logger, LogFields

The build logger's child for a module: every line starts with [tag] ([silgi] without a tag), fields follow as key=value, and an error's stack goes on the next lines. info and below print on stdout, warn and above on stderr. SILGI_LOG_FORMAT=json prints one JSON line per record instead, SILGI_LOG_LEVEL sets the level (default info) and --debug sets debug. Values under keys such as password, token, secret or authorization print as [redacted].

function useLogger(tag?: string): Logger
type LogFields = Record<string, unknown>
interface Logger {
  readonly level: "trace" | "debug" | "info" | "warn" | "error" | "fatal" | "silent";
  trace(msg: string, fields?: LogFields): void;   // also (error: unknown, msg?: string, fields?: LogFields)
  debug: …; info: …; warn: …; error: …; fatal: …;
  child(bindings: LogFields): Logger;
  enabled(level: "trace" | "debug" | "info" | "warn" | "error" | "fatal"): boolean;
}

logger

Deprecated, kept through 1.x: use useLogger(tag). Returns { info, warn, error } that take any arguments, join them as console does (objects as JSON) and write the line through useLogger(tag).

function logger(tag: string): { info(...args: unknown[]): void; warn(...args: unknown[]): void; error(...args: unknown[]): void }

addPage, extendPages, PageInput

addPage adds a route. PageInput is a route record: name, path, file, optional loader and optional children of the same shape. extendPages receives the scanned route list and may change it, once the pages directory was scanned.

function addPage(page: PageInput): void
function extendPages(fn: (routes: RouteRecord[]) => void | Promise<void>): void
import { addLayout, addPage, createResolver, defineModule, extendPages } from "silgi/kit";

export default defineModule({
  meta: { name: "docs-pages" },
  setup() {
    const { resolve } = createResolver(import.meta.url);
    addPage({ name: "docs", path: "/docs", file: resolve("./runtime/docs.tsx") });
    addLayout({ name: "docs", file: resolve("./runtime/layout.tsx") });
    extendPages((routes) => {
      routes.sort((a, b) => a.path.localeCompare(b.path));
    });
  },
});

addLayout

Registers a layout by name. A layout of the same name in app/layouts/ wins.

function addLayout(layout: { name: string; file: string }): void

addRouteMiddleware

Registers route middleware by name. global: true runs it on every navigation. A scanned file of the same name wins.

function addRouteMiddleware(entry: { name: string; path: string; global?: boolean }): void

addAppPlugin

Adds an app plugin file. It runs before the app's own plugins. mode limits it to "client" or "server"; the default is "all".

function addAppPlugin(plugin: string | { src: string; mode?: "client" | "server" | "all" }): void

addServer

Adds the module's door: a .ts file read like server/index.ts, its re-exports and export default defineServer(…) included. Client code under root (default the file's folder) reads it as #server, and the app takes a handler with export { x } from "#server/<meta.name>". The module's plugins, middleware and handlerMiddleware run before the layers' and the app's, in module order; handlerMiddleware governs the door's own handlers. A relative path is resolved from the app's root. A module without meta.name fails with addServer(file) needs meta.name in defineModule, and a second call with Module "x" has one door; list its handlers in <file>.

function addServer(file: string, options?: { root?: string }): void
import { addAppPlugin, addRouteMiddleware, addServer, createResolver, defineModule } from "silgi/kit";

export default defineModule({
  meta: { name: "runtime" },
  setup() {
    const { resolve } = createResolver(import.meta.url);
    addServer(resolve("./runtime/server.ts"));
    addAppPlugin({ src: resolve("./runtime/app-plugin.ts"), mode: "client" });
    addRouteMiddleware({ name: "auth", path: resolve("./runtime/auth.ts"), global: false });
  },
});
setup() {
  const { resolve } = createResolver(import.meta.url);
  addServer(resolve("./runtime/server.ts"), { root: resolve("./runtime") });
}

A value from module options reaches a door through addTemplate: the build reads a door as text and runs nothing, so a route is a literal in a generated file. Write the door and its definitions with write: true to a folder of their own in the build directory, and pass the door's path. Their getContents runs before the scan, so it reads options, not the routes.

setup(options, silgi) {
  addTemplate({ filename: "feed/q.ts", write: true, getContents: () => `import { defineHandler } from "silgi/server";\nexport const feed = defineHandler({ route: ${JSON.stringify(options.path)}, handler: () => "ok" });\n` });
  addTemplate({ filename: "feed/server.ts", write: true, getContents: () => `export { feed } from "./q.ts";\n` });
  addServer(join(silgi.options.buildDir, "feed/server.ts"));
}

addTask

Adds a task to the app's: file is an absolute .ts path whose default export is defineTask(…), read as a server/tasks file is, and name follows the task name rule (segments of A-Z, a-z, 0-9, _ and -, joined by :). A name the app or a layer already has fails the build: Task "db:migrate" of module @silgi/db conflicts with server/tasks/db/migrate.ts.

function addTask(task: { name: string; file: string }): void

addServerAssets

Mounts a directory of the module read-only as useStorage("assets:<name>") in the server. The files are data: a build inlines each file up to 1 MB in the server bundle and copies a larger one to .output/server/assets/<name>/ (on cloudflare every file is inlined), and silgi dev reads the directory live. name matches /^[a-z0-9][a-z0-9-]*$/; dir is absolute or relative to the app's root and must exist. server is the app's own server/assets, so addServerAssets({ name: "server", … }) fails with silgi/kit: addServerAssets name "server" is the app's server/assets; a missing directory fails with silgi/kit: addServerAssets dir "<path>" does not exist and a name added twice with silgi/kit: addServerAssets name "icons" is added twice. An adding call: from build:before on it throws as the others do.

function addServerAssets(options: { name: string; dir: string }): void
setup() {
  addServerAssets({ name: "icons", dir: createResolver(import.meta.url).resolve("./icons") });
}

addStorageDriver

Adds a storage driver name to the build's driver table and its preset check: a storage mount with driver: name imports the default export of from (an absolute path or a package specifier), a function (options) => StorageDriver that gets the mount's other options, and the build refuses the mount on a preset that is not in presets, with the message of a built-in driver (Storage mount "x" uses the "name" driver, which the "cloudflare" preset cannot run (it runs on: bun, node)). name matches /^[a-z0-9][a-z0-9-]*$/; the factory also gets the mount's name as its second argument. from is an absolute path or a package specifier resolved from the app root (a relative path fails with from must be an absolute path or a package specifier). A built-in name fails with silgi/kit: addStorageDriver("fs"): a built-in driver has this name, an unknown preset with silgi/kit: addStorageDriver("db"): unknown preset "deno", and a name added twice with silgi/kit: addStorageDriver("db"): added twice. An adding call: from build:before on it throws as the others do. @silgi/db adds db with it.

function addStorageDriver(driver: { name: string; from: string; presets: string[] }): void
setup() {
  addStorageDriver({ name: "mine", from: createResolver(import.meta.url).resolve("./driver.ts"), presets: ["bun", "node"] });
}

addTemplate, addTypeTemplate, TemplateInput

addTemplate adds a virtual module with a filename and getContents(silgi). Its id is exactly #silgi/<filename>, extension included, and the contents are loaded by that extension: .ts, .mts and .cts as TypeScript, .tsx, .jsx, .json and .css as themselves, anything else as JavaScript. A .css template is dropped from the server bundle. write: true also writes it to the build directory, and silgi dev rewrites it after files are added or removed. It returns the template. addTypeTemplate adds a .d.ts file that is written to types/ in the build directory and referenced from its silgi.d.ts.

function addTemplate(template: TemplateInput): TemplateInput
function addTypeTemplate(template: { filename: string; getContents(silgi: SilgiContext): string | Promise<string> }): void

updateTemplates

Recomputes the templates that filter accepts (all by default) and rewrites their write: true files in the build directory. The virtual modules read their contents on every load. It works in setup and in any hook.

function updateTemplates(options?: { filter?: (template: { filename: string }) => boolean }): Promise<void>

addImports, KitImport

addImports makes an export auto-imported: { name, as?, from }.

function addImports(imports: KitImport | KitImport[]): void

addBunPlugin

Adds a Bun.build plugin to the client build, the server build, or both (the default). In silgi dev, the client bundler gets every plugin added for the client; the server build always drops .css files before a plugin can load them.

function addBunPlugin(plugin: BunPlugin, target?: { client?: boolean; server?: boolean }): void

SilgiHooks, TsConfigFile

The map of build hook names to their functions, the type behind silgi.hooks and behind hooks in the config, and TsConfigFile, what prepare:types gets for each context. silgi exports the same types; the silgi page lists the hooks.

RouteRecord

One scanned route: name, path, file, an optional loader and children of the same shape. It is the item type of the list that extendPages and the pages:extend hook receive.

Hooks

The type of silgi.hooks on the context: 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. silgi/app exports the same type.

updateRuntimeConfig

Merges a patch over runtimeConfig: a key in the patch wins over the same key in the app's config. SILGI_* environment variables still override the result at runtime.

function updateRuntimeConfig(patch: Record<string, unknown>): void
import { addBunPlugin, addImports, addTemplate, addTypeTemplate, defineModule, updateRuntimeConfig } from "silgi/kit";
import type { KitImport, TemplateInput } from "silgi/kit";

export default defineModule({
  setup() {
    const template: TemplateInput = { filename: "theme.js", write: true, getContents: () => `export const useTheme = () => "dark";\n` };
    const imports: KitImport[] = [{ name: "useTheme", from: "#silgi/theme.js" }];
    addTemplate(template);
    addTypeTemplate({ filename: "theme.d.ts", getContents: () => `declare module "#silgi/theme.js" {\n  export function useTheme(): string;\n}\n` });
    addImports(imports);
    addBunPlugin({ name: "noop", setup() {} }, { server: false });
    updateRuntimeConfig({ public: { theme: "dark" } });
  },
});