SSR framework for Svelte 5 + Bun with islands-based selective hydration
Extensions (hooks & filters)
Extension points for Mochi.serve(). Two kinds:
- Hooks run a user function at a specific framework moment (no return value).
- Filters replace a framework default value (receive the existing value, return the new one).
Names use a namespace:camelCase convention. Each name is registered in a typed registry; whether the user callback can be sync or async is declared per name and enforced by TypeScript — sync filters in hot paths can’t accidentally be async.
Hooks:
mochi:init— asyncmochi:ready— asyncmochi:shutdown— asyncroute:matched— sync
Filters:
csrf:formContentTypes— synccsrf:protectedMethods— synccsrf:trustedOrigins— synccsrf:check— synccookie:defaults— synchtml:shell— syncserverIsland:secretKey— asynccompile:preprocessors— syncpublicDir:scan— async
Hooks
mochi:init
Fires as the very first thing inside Mochi.serve(), before any framework state is set up. Async.
await Mochi.serve({
hooks: {
'mochi:init': async ({ options }) => {
await warmCache(options);
},
},
routes,
});mochi:ready
Fires after Bun.serve() returns the bound server, just before Mochi.serve() resolves. Use it to do post-bind setup that needs the live Server instance (warm caches, register with service discovery, kick off background workers). Async.
await Mochi.serve({
hooks: {
'mochi:ready': async ({ server }) => {
await registerWithServiceDiscovery(server.url);
},
},
routes,
});mochi:shutdown
Fires when the framework receives SIGTERM or SIGINT. The framework then calls server.stop(). A second signal force-exits with code 1. Async — the framework awaits the hook before stopping the server.
await Mochi.serve({
hooks: {
'mochi:shutdown': async ({ signal }) => {
log.info(`Got ${signal}, draining…`);
await db.close();
},
},
routes,
});The framework installs the signal listeners as part of serve(). Pre-existing user listeners on those signals are not displaced — Node.js dispatches signals to every registered listener.
route:matched
Fires when a Mochi.page / Mochi.api / Mochi.ws / Mochi.sse route matches an incoming request, after the CSRF check passes (for page/api) and before middleware/handler runs. Sync — observation only; do heavier work in a handle middleware. The kind field tells you which route type matched.
await Mochi.serve({
hooks: {
'route:matched': ({ pattern, kind, request }) => {
tracer.startSpan(`${kind}:${pattern}`, { method: request.method });
},
},
routes,
});The hook does not fire when the framework rejects the request before route handling (e.g. CSRF block) — those cases reach the request event with the rejection’s status instead.
getRequestContext() is available inside the hook for all four kinds — page, api, ws, sse — and returns a context with the matched requestId, url, and params.
Filters
csrf:formContentTypes
Override the Set<string> of content types that gate the built-in CSRF check. Resolved once at startup. Sync.
await Mochi.serve({
filters: {
'csrf:formContentTypes': (types) => new Set([...types, 'application/csp-report']),
},
routes,
});Default exported as DEFAULT_FORM_CONTENT_TYPES from mochi-framework.
csrf:protectedMethods
Override the Set<string> of HTTP methods that the CSRF check applies to. Resolved once at startup. Sync.
await Mochi.serve({
filters: {
'csrf:protectedMethods': (methods) => {
methods.delete('DELETE');
return methods;
},
},
routes,
});Default exported as DEFAULT_PROTECTED_METHODS from mochi-framework.
csrf:trustedOrigins
Override the Set<string> of cross-origin sources allowed past the CSRF check. Seeded from csrf.trustedOrigins (an array on Mochi.serve()). Resolved once at startup. Sync.
await Mochi.serve({
filters: {
'csrf:trustedOrigins': (origins) => {
origins.add('https://embed.example');
return origins;
},
},
routes,
});csrf:check
Override the framework’s CSRF decision for the current request. The filter receives the framework’s default decision — null if the request would pass, a Response (typically 403) if it would be blocked. Return the input unchanged to delegate to the framework, null to bypass, or a fresh Response to substitute a custom block.
await Mochi.serve({
filters: {
'csrf:check': (decision, { request, url }) => {
// Webhook endpoint with its own auth — bypass CSRF entirely.
if (url.pathname.startsWith('/webhooks/')) {
return null;
}
return decision;
},
},
routes,
});Use sparingly: bypassing CSRF on a state-mutating endpoint reopens the attack the check exists to prevent. For most needs prefer the narrower csrf:trustedOrigins filter or csrf.checkOrigin: false on Mochi.serve().
cookie:defaults
Default CookieSerializeOptions merged into every cookies.set() call. Per-call options win on a per-field basis. path and domain from defaults also apply to cookies.delete() so the browser still matches the original Set-Cookie. Resolved once at startup. Sync.
await Mochi.serve({
filters: {
'cookie:defaults': () => ({ secure: true, httpOnly: true, sameSite: 'Lax', path: '/' }),
},
routes,
});html:shell
Modify the HTML shell template once at startup. The value is the resolved template string with {{mochi.head}}, {{mochi.css}}, {{mochi.body}}, placeholders intact. Sync.
await Mochi.serve({
filters: {
'html:shell': (tpl) => tpl.replace('{{mochi.head}}', '<meta name="csp-nonce" content="abc123">{{mochi.head}}'),
},
routes,
});Use this when you only want to inject a snippet — for full ownership of the shell, set htmlShell directly.
serverIsland:secretKey
Override the HMAC key used to sign server-island props. The default value is the MOCHI_KEY env var (or a fresh random key if unset). Use this to source the key from KMS / Vault / a secret manager. Async.
await Mochi.serve({
filters: {
'serverIsland:secretKey': async () => {
const raw = await kms.getSecret('mochi-island-key');
return Buffer.from(raw, 'base64url');
},
},
routes,
});The envKeyPresent field on the filter context tells you whether MOCHI_KEY was set, in case you want to fall back to the env-derived default.
compile:preprocessors
A list of Svelte PreprocessorGroup to run on every .svelte source file before compilation. Applies to both server and client targets — branch on target in the filter context if you only want one. Sync (the preprocessor list is sync; Svelte’s preprocess() itself is awaited internally).
import autoprefixer from 'autoprefixer';
import postcss from 'svelte-preprocess';
await Mochi.serve({
filters: {
'compile:preprocessors': () => [postcss({ postcss: { plugins: [autoprefixer] } })],
},
routes,
});Default is []. Preprocessors do not currently apply to .md / .svx files (mdsvex handles those itself).
publicDir:scan
Modify the Map<urlPath, diskPath> of files served from the public directory. The filter receives a fresh copy after each scan (initial startup + every dev-mode public/ change), so in-place mutation is safe. Use it to add virtual files (entries that map to disk paths the framework didn’t discover), shadow built-in routes, or rename URLs. Async.
await Mochi.serve({
filters: {
'publicDir:scan': async (files) => {
files.set('/robots.txt', '/etc/mochi/robots.generated.txt');
return files;
},
},
routes,
});A Mochi.page / Mochi.api route on the same URL still wins — the filter only adds entries; the wiring step skips entries whose URL is already a user route, with a [mochi] warning.