Reference
Plugins
Features beyond regions ship as separate packages. Install one and list its plugin once beside ogygia() in your Vite config, and the build wires in its server and browser halves. This page lists the packages and describes the plugin API they are built on.
On this page
- Turn a feature on
- The packages
- Content: @ogygia/content
- Flags: @ogygia/flags
- Postrender: @ogygia/postrender
- Federation: @ogygia/federation
- Server router: @ogygia/router
- Devtools and profiler: @ogygia/devtools
- Testing and the CLI
- The plugin API
- The plugin: definePlugin
- Vite hooks of its own
- The build context: b
- The server half: defineServer
- The contexts: app and ctx
- Request facts
- The browser half: defineClient
- Helpers
The core package, ogygia, is the region system: islands, lakes, holes, held regions, the client router and the server handle. Every other feature is a separate package, including content collections, flags, postrender, federation, the server router, the devtools dock and the profiler. You install only what you use, so an app that never reads a flag carries none of the flag code.
Turn a feature on
Install the package, then list its plugin once in the plugins array of your Vite config, beside ogygia(). Each plugin is a Vite plugin in its own right, and ogygia() finds it in that list. Each factory is imported from <package>/vite:
npm i @ogygia/flags @ogygia/postrenderpnpm add @ogygia/flags @ogygia/postrenderimport { sveltekit } from '@sveltejs/kit/vite';
import { ogygia } from 'ogygia/vite';
import { flags } from '@ogygia/flags/vite';
import { postrender } from '@ogygia/postrender/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
ogygia(),
flags(),
postrender({ ttl: 600 }),
sveltekit()
]
});ogygia() comes first, the feature plugins follow it, and sveltekit() goes last. ogygia.handle() in hooks.server.ts already runs the server half of every listed plugin, and the page runtime boots every browser half. What the Vite config can’t carry is anything live or app-specific: a flag vendor, your federation identity, your route table, a store client. Most packages therefore also ask for a line or two in hooks.server.ts, and each entry below says which.
These rules hold for every plugin:
- Call the factory. Write
flags(), notflags; the build tells you if you forget. - List each plugin once. A plugin listed twice is a build error.
- Only alongside
ogygia(). A plugin listed withoutogygia()fails the build with an explanation. The oldpluginsoption ofogygia()has been removed, and passing it is a build error that names the new form. - Options are plain JSON. They reach the plugin’s server half at run time. Live objects (a store client, a vendor SDK) are set up in
hooks.server.tsthrough the package’s own API. - List it only where you want it. The list is code, so a plugin can depend on the mode:
...(mode !== 'production' ? [devtools()] : []).
The packages
| Package | Plugin | What it does |
|---|---|---|
@ogygia/content | content({ markdown, presets }) | Content collections, markdown (mdsvex + Shiki), the docs shell and search. Its svelte config helpers preprocess() / extensions() come from @ogygia/content/vite too. |
@ogygia/flags | flags() | Flags & experiments: flag(), decide(). List the plugin wherever flags are read: it reports each read so a stored page never keeps one variant, and it runs the source and exposures. |
@ogygia/postrender | postrender({ ttl, default }) | Postrender: pages stored whole on their first request and served as bytes. |
@ogygia/federation | federation() | Fragment federation: federate(), mount(). |
@ogygia/router | router() | The server router: routes as values. The plugin links the CSS of the pages it renders. |
@ogygia/devtools | devtools({ dock, profiler }) | The devtools dock and the SSR profiler. |
@ogygia/testing | none | Test helpers for Playwright. Import it in tests. |
@ogygia/cli | none | The command: npx ogygia init, ogygia mcp, the AI skill. npx ogygia … runs it for you. |
Every package takes ogygia as a peer dependency, and they all release together under one version number, so keep them on the same version as ogygia.
When several packages need a handle, the order in the sequence() is postrender first, then ogygia, then the router:
export const handle = sequence(postrender.handle(), ogygia.handle(), router.handle);Content: @ogygia/content
pnpm add @ogygia/contentplugins: [ogygia(), content({ markdown: {} }), sveltekit()]Nothing goes in hooks.server.ts. Markdown also needs extensions() and preprocess() in svelte.config.js, and a second /// <reference types="@ogygia/content/types" /> line in src/ogygia.d.ts; Install shows both. Guide: Content collections.
Flags: @ogygia/flags
pnpm add @ogygia/flagsplugins: [ogygia(), flags(), sveltekit()]Native flags need nothing in hooks.server.ts. Call decide() there when you connect a vendor source, an exposure sink or a QA override gate. On plain SvelteKit, also import your flags module there, so every flag is registered before the first request:
import { decide } from '@ogygia/flags';
import { openfeature } from '@ogygia/flags/openfeature';
import '$lib/flags';
decide({ source: openfeature(OpenFeature.getClient()), exposure: (events) => analytics.track(events) });Guide: Flags & experiments.
Postrender: @ogygia/postrender
pnpm add @ogygia/postrenderplugins: [ogygia(), postrender({ ttl: 600 }), sveltekit()]postrender.handle() holds the live store and CDN edges, and it must come before ogygia.handle(), because a stored page is served before anything renders:
import { sequence } from '@sveltejs/kit/hooks';
import * as ogygia from 'ogygia/server';
import { postrender, valkey } from '@ogygia/postrender';
export const handle = sequence(postrender.handle({ store: valkey(client) }), ogygia.handle());Guide: Postrender.
Federation: @ogygia/federation
pnpm add @ogygia/federation @ogygia/routerplugins: [ogygia(), federation(), sveltekit()]The app’s one federate() call lives in a .server.ts module, and hooks.server.ts must import that module, directly or through a router that uses its peers, so it runs on boot. The plugin then serves every /og/ endpoint from ogygia’s handle, with no route files:
import * as ogygia from 'ogygia/server';
import '$lib/federation.server'; // runs federate() on boot
export const handle = ogygia.handle();Guide: Fragment federation.
Server router: @ogygia/router
pnpm add @ogygia/routerplugins: [ogygia(), router(), sveltekit()]Your route table serves requests through its own handle, placed after ogygia’s (or from a Kit catch-all route; see Mounting):
import { sequence } from '@sveltejs/kit/hooks';
import * as ogygia from 'ogygia/server';
import { router } from '$lib/router'; // your routes({ … }) table
export const handle = sequence(ogygia.handle(), router.handle);Guide: Server router.
Devtools and profiler: @ogygia/devtools
pnpm add @ogygia/devtoolsplugins: [ogygia(), devtools({ profiler: true }), sveltekit()]ogygia.handle() mounts the dock and the profiler, so nothing is required in hooks.server.ts. In production the profiler UI needs OGYGIA_PROFILER_SECRET in the environment. To keep profiler reports in a database instead of process memory, set a store once in hooks.server.ts, before the first request:
import { env } from '$env/dynamic/private';
import { setProfilerStore } from '@ogygia/devtools/profiler/storage';
import { postgresStore } from '@ogygia/devtools/profiler/storage/postgres';
setProfilerStore(postgresStore(env.DATABASE_URL!));sqliteStore and redisStore come from @ogygia/devtools/profiler/storage/sqlite and …/redis. Guides: Devtools and Profiler.
Testing and the CLI
@ogygia/testing and @ogygia/cli have no plugin. Import @ogygia/testing in your Playwright tests (Testing). @ogygia/cli is the ogygia command, which npx ogygia init adds to your devDependencies (CLI).
The plugin API
First-party, not stable
The packages above 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.
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/vite, which returns a Vite plugin |
| The server half | a module the plugin names in server | defineServer from ogygia/plugin |
| The browser half | a module the plugin names in client | defineClient from ogygia/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/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/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/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/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/plugin' { interface RequestFacts { … } }.
The browser half: defineClient
import { defineClient } from 'ogygia/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/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/plugin/client | defineClient and its types. |
ogygia/plugin/utils | Pure helpers with no context, safe in any bundle: fnv1a32, escapeAttr, escapeText, SizedLru, sourceTag, the Standard Schema types. |
ogygia/plugin/css | componentCssHead(components): the CSS head tags for components a programmatic router rendered. |
ogygia/plugin/testing | startPlugin(server, { name, options }): run a server half in a unit test, with no server. |
import { startPlugin } from 'ogygia/plugin/testing';
import server from '../src/server.ts';
const { ctx } = startPlugin(server, { name: '@ogygia/flags' });
await server.request?.(ctx(new Request('http://localhost/')));