@ogygia/core
Writing a plugin
The plugin API every ogygia package is built on. A plugin is three files (the Vite plugin, a server half and a browser half), the build imports them statically, and plugins talk to each other through shared hooks and request facts.
On this page
Every package beside ogygia (content, flags, postrender, federation, the server router, devtools) is a plugin: a Vite plugin the app lists once beside ogygia(), with a server half that runs inside ogygia.handle() and a browser half that boots with the page runtime. This page describes that API. For the app side (installing a package and listing its plugin), see Packages.
First-party, not stable
The packages are built on the API described below. It is public so that you can read how they work, but it is first-party only for now, and its shape can change in any release without notice, so don’t build a published plugin on it yet.
The first-party packages import it from @ogygia/core, which they take as a peer dependency. ogygia re-exports the same modules subpath for subpath, so ogygia/plugin and @ogygia/core/plugin are one module, and the examples below work with either.
A plugin consists of three pieces, each in its own file, so that server code never reaches a browser bundle:
| Piece | Where | Made with |
|---|---|---|
| The plugin | the package’s ./vite export, listed beside ogygia() in vite.config | definePlugin from @ogygia/core/vite, which returns a Vite plugin |
| The server half | a module the plugin names in server | defineServer from @ogygia/core/plugin |
| The browser half | a module the plugin names in client | defineClient from @ogygia/core/plugin/client |
The plugin names its halves: server and client hold each module’s location, resolved relative to the plugin’s own file with import.meta.resolve, and either one may be omitted. They are files inside the package itself, so each module exists only once, and they need no entry in the package’s exports, which remain yours to name. The build imports them statically, so nothing is looked up at run time, and a feature whose plugin is not listed costs zero bytes.
The plugin: definePlugin
import { definePlugin, type OgygiaPlugin } from '@ogygia/core/vite';
export function postrender(options: { ttl?: number } = {}): OgygiaPlugin<{ ttl: number }> {
return definePlugin({
name: '@ogygia/postrender', // the package name: its identity
server: import.meta.resolve('./server.ts'),
options: { ttl: options.ttl ?? 86400 },
build(b) {
b.routeConfig('postrender'); // pages may say `export const config = { postrender: false }`
}
});
}| Field | What it is |
|---|---|
name | The package name. It must resolve from the app. |
server | The server half’s location: import.meta.resolve('./server.ts'). A built package ships ./server.js beside the plugin’s file, and its build rewrites the extension. |
client | The browser half’s location, the same way. |
options | Plain JSON. The server half reads it as app.options and ctx.options. |
enforce | 'pre' or 'post': run before or after the other plugins, in every realm. Otherwise, list order. |
build(b) | Build-time contributions, with the build context b. |
definePlugin returns a Vite plugin that carries the definition under its own symbol (Symbol.for('ogygia.plugin')). That symbol is how ogygia() finds it in the plugins list, so a plugin’s name needs no naming convention beyond its package name.
Vite hooks of its own
A plugin that needs its own Vite hooks returns an array containing its definePlugin(...) and a plain Vite plugin. Vite flattens the array, so the app still lists a single yaml(). content() and devtools() work this way.
import { definePlugin } from '@ogygia/core/vite';
export function yaml() {
return [
definePlugin({ name: 'ogygia-yaml', server: import.meta.resolve('./server.ts') }),
{ name: 'ogygia-yaml:transform', transform(code, id) { /* a plain Vite plugin */ } }
];
}The build context: b
build(b) runs once, when Vite loads its config. b.root is the app’s root, and b.dev is true under vite dev.
| Member | What it asks the build for |
|---|---|
routeConfig(key) | A key this plugin reads inside Kit’s own config page option (export const config = { <key>: false } in a page or layout file). Read at build time, merged the way Kit merges config, and answered by ctx.routeConfig(key). |
foreignHydrate() | Keep the exports that let another app’s page wake this app’s islands (fragment federation). Off by default: it costs every island a little. |
macro(name, handler) | An import.meta.og.<name>(…) construct this plugin compiles. The handler gets the module’s code and returns the new code. Core’s own macros (wire, $, source, store, bake) cannot be claimed. |
markup(source) | A file format that becomes Svelte through a preprocessor (markdown). It hands back the region transform to call on the preprocessed code, so the format’s islands are found. |
events({ lazy }) | Compile the framework’s events in, browser and server (ogygia/events). With no plugin asking, every emit folds away. lazy: measure only once someone opens a tool. |
measure() | Record the build’s measurement data (region detail, chunk contents, source maps, the module map): what a profiler reads. |
files({ root, dirs, files }) | Compile a package’s own files as app source: the regions of a UI the plugin serves. |
pageModules({ importers, files }) | Modules that render pages outside Kit’s routes. Every component they import gets its CSS linked when it renders. |
The server half: defineServer
import { defineServer } from '@ogygia/core/plugin';
export default defineServer<{ ttl: number }, { hit: boolean }>({
setup(app) { /* once: app.options, app.notify, app.ask */ },
state: () => ({ hit: false }), // fresh per request: ctx.state
async request(ctx) { /* every request */ },
async page(ctx) { /* a page request: return a Response to answer it */ },
async response(ctx, res) { /* return a Response to replace the page's */ },
done(ctx) { /* the request ended */ },
async invalidate(tags) { /* a shared hook */ }
});Each hook has a kind, which determines what its return value does.
| Hook | Kind | When it runs |
|---|---|---|
setup(app) | once | When the server starts. |
state() | per request | Makes this plugin’s ctx.state for one request. |
handle(ctx, next) | around | Wraps the whole request, like Kit’s handle. next() runs the rest and returns its response. next(options) passes Kit resolve() options. The first plugin is the outermost. |
request(ctx) | first | Every request, before the split into pages and region requests. The first Response answers it. |
page(ctx) | first | A page request, before Kit renders. The first Response answers it (a stored copy). |
response(ctx, res) | sequential | The page’s response, before it streams out. A Response replaces it. |
done(ctx) | parallel | The request’s end, always, even when it failed or was answered early. It must not throw and must not wait on anything slow. |
Shared hooks are how plugins communicate with each other. A plugin implements one as a method of its server half, and any code can call it through app:
app.notify.<hook>(…)runs the hook on every plugin that has it and resolves when all of them are done.app.ask.<hook>(…)returns the first answer that is notundefined, in plugin order.
Core owns the common names: invalidate(tags) (drop stored pages with these tags), invalidated(tags) (stored pages were dropped) and lastBuild(version). It is normal for no plugin to implement a hook, in which case notify does nothing and ask answers undefined. A plugin’s own hooks carry its name and are typed through declaration merging:
declare module '@ogygia/core/plugin' {
interface ServerHooks { myPluginReset(path: string): void }
}The contexts: app and ctx
app is the server process as one plugin sees it. setup receives it, and every ctx.app refers to it.
| Member | What it is |
|---|---|
name · options | The plugin’s name and its options from vite.config. |
notify · ask | Call the shared hooks (above). |
facts(request) · current() | A request’s facts. current() finds the request the running code belongs to (a load, a render), or null. |
sign(message) · verify(message, signature) | The app’s region key, as HMAC. The key itself is never handed out. |
capability | mint and open a signed URL for an endpoint the plugin serves, with the lifetime and renewal of a hole. |
devtools(event) | Record an event into the current request’s devtools stream. A no-op unless devtools is on for that browser. |
warn(message) | Warn once per message, prefixed with the plugin’s name. |
ctx is one request as one plugin sees it. handle receives a smaller version with only event, options and app.
| Member | What it is |
|---|---|
event | Kit’s own RequestEvent, untouched. |
path | The decoded pathname. |
state · options · app | This plugin’s per-request state, its options, its app. |
facts | The request’s shared facts (below). |
routeConfig(key) | This request’s value for a claimed config key, or undefined when nothing sets it. |
renderHole(endpoint) | Render one signed hole in place, with this request’s cookies. Its HTML, or null. |
perVisitor(reason) | Say this render belongs to one visitor. The first reason is kept. |
Request facts
Facts are what a request has learned so far, shared by every plugin and by core. Whichever code learns a fact writes it and whichever code needs it reads it, without either one importing the other.
| Fact | Meaning |
|---|---|
storable | This render may be stored and served to others later. Its holes get long-lived URLs. |
perVisitor | The first reason this render is per-visitor (a flag read, a streamed promise, a personal cookie), or null. |
streamed | The page streams load promises after the document. |
private | The response belongs to one browser (devtools is on for it). Never store or share it. |
answerForm | An app transform renders this router-fetched page in a form a store keyed by path does not hold. |
sources | Data-source receipts the render filed, collected only once a plugin sets a list here. |
assignments | Every flag decided for this request, name to variant. |
A plugin adds facts of its own the same way it adds hooks, with declare module '@ogygia/core/plugin' { interface RequestFacts { … } }.
The browser half: defineClient
import { defineClient } from '@ogygia/core/plugin/client';
export default defineClient({
boot({ kitPage }) { /* once, before the first region wakes */ },
regionMount(el, { entry, urls }) { /* a region's component started */ },
regionUnmount(el, { entry }) { /* …and left the page */ }
});| Hook | When it runs |
|---|---|
boot({ kitPage }) | Once, as the runtime boots, before any region wakes. kitPage is true on a csr = true page, where Kit hydrates and the region hooks do not fire. |
regionMount(el, { entry, urls }) | A region’s component started in the browser. entry is its component’s stable module URL, urls the files it runs from. |
regionUnmount(el, { entry }) | A region’s component left the page. |
The browser half is part of the boot’s static graph, so it ships to every page. Keep it small and load anything heavy on demand through a dynamic import of its own. When the build contains no browser half, core’s calls into these hooks are removed.
Helpers
| Import | What it holds |
|---|---|
@ogygia/core/plugin | defineServer, the types (ServerApp, RequestContext, RequestFacts, ServerHooks), region values (region, htmlRegion, deferredRegion), route options for a programmatic router (registerRouteOptions), late regions (openLateScope, takeLateRegions, lateSlot, lateChunk). |
@ogygia/core/plugin/client | defineClient and its types. |
@ogygia/core/plugin/utils | Pure helpers with no context, safe in any bundle: fnv1a32, escapeAttr, escapeText, SizedLru, sourceTag, the Standard Schema types. |
@ogygia/core/plugin/css | componentCssHead(components): the CSS head tags for components a programmatic router rendered. |
@ogygia/core/plugin/testing | startPlugin(server, { name, options }): run a server half in a unit test, with no server. |
import { startPlugin } from '@ogygia/core/plugin/testing';
import server from '../src/server.ts';
const { ctx } = startPlugin(server, { name: '@ogygia/flags' });
await server.request?.(ctx(new Request('http://localhost/')));