SSR framework for Svelte 5 + Bun with islands-based selective hydration
On this page
Middleware (hooks)
Middleware uses SvelteKit-style Handle functions registered via Mochi.serve({ handle }). Each handle receives { event, resolve }, mutates event as needed, calls resolve(event) to continue the chain, and returns the resulting Response.
Handle
A Handle is async ({ event, resolve }) => Response. event carries { request, url, server, locals, kind }; resolve(event) invokes the next middleware or the final route handler and returns its Response.
// file: src/handle.ts
import type { Handle } from 'mochi-framework';
export const auth: Handle = async ({ event, resolve }) => {
if (!event.request.headers.get('Authorization')) {
return new Response('Unauthorized', { status: 401 });
}
return resolve(event);
};Do NOT return without calling resolve(event) when you intend the request to continue; instead, always either short-circuit with your own Response or return resolve(event).
Do NOT call resolve(event) without await (or return) when you need to inspect or post-process the response; instead, const response = await resolve(event) and return response.
event.locals
event.locals is a per-request object for passing data between middleware layers and into route handlers. Read it from any server-side context via getRequestContext().locals.
// file: src/handle.ts
import type { Handle } from 'mochi-framework';
export const attachUser: Handle = async ({ event, resolve }) => {
event.locals.user = await loadUser(event.request);
return resolve(event);
};event.kind
Every event carries a kind discriminator describing what the framework is about to do with the request:
| Value | When |
|---|---|
'page' | Mochi.page route (GET render or POST form action) |
'api' | Mochi.api route |
'asset' | Framework static asset (.js / .css client bundle or the dev stats route) |
'fallback' | Unmatched URL — will be passed to your fetch handler |
'error' | Unmatched URL with no fetch configured — framework will render a 404 |
kind is set once at construction and isn’t mutated; an error thrown during a Mochi.page render is still kind: 'page'.
Use it to opt out of per-request work for framework assets:
// file: src/handle.ts
import type { Handle } from 'mochi-framework';
export const auth: Handle = async ({ event, resolve }) => {
if (event.kind === 'asset') return resolve(event);
if (!event.request.headers.get('Authorization')) {
return new Response('Unauthorized', { status: 401 });
}
return resolve(event);
};sequence
Compose multiple handles into one with sequence(...handlers). Handles run in order: the first handle’s pre-processing runs first, and its post-processing runs last (nested-middleware semantics).
// file: src/index.ts
import { Mochi, sequence } from 'mochi-framework';
import { auth, logging, rateLimit } from './handle';
await Mochi.serve({
handle: sequence(auth, logging, rateLimit),
routes: {
'/': Mochi.page('./src/Home.svelte'),
},
});Do NOT assign multiple handles to handle directly; instead, wrap them in sequence().
resolve(event, opts)
resolve accepts an options bag for post-processing the response:
transformPage({ html, done })— rewrite the HTML body before it is sent. SeetransformPage.filterResponseHeaders(name, value)— returntrueto keep a header,falseto drop it.
// file: src/handle.ts
import type { Handle } from 'mochi-framework';
export const stripServerHeader: Handle = ({ event, resolve }) =>
resolve(event, {
filterResponseHeaders: (name) => name.toLowerCase() !== 'server',
});When composed with sequence, transformPage runs in reverse order (inner handle transforms first, outer wraps the result), and filterResponseHeaders uses first-defined-wins — only the earliest handle’s filter is applied.
compress
Built-in middleware factory for response compression. Negotiates between gzip and brotli based on the client’s Accept-Encoding. Place it innermost in sequence(...) so it sees the body produced by the rest of the chain (and by transformPage):
// file: src/index.ts
import { Mochi, sequence, compress } from 'mochi-framework';
await Mochi.serve({
handle: sequence(auth, logging, compress()),
routes,
});Options:
methods— the encodings the server is willing to use, gating which ones may be picked. Defaults to['brotli', 'gzip']. The client’sAccept-Encodingdecides the winner among them (header order + q-values; q=0 forbids). The array order is only used as a tiebreak when the client expresses no preference (e.g.Accept-Encoding: *).brotliQuality— brotli quality level0..11. Defaults to4. The zlib default of11is designed for static prebuilds and is too slow for per-request SSR; raise it only when the response is cached.
// Bump brotli quality for pages you also cache
sequence(auth, compress({ brotliQuality: 6 }));
// Disable brotli (e.g. CPU-constrained host)
sequence(auth, compress({ methods: ['gzip'] }));compress() is a no-op in development so the debug bar can render the uncompressed HTML response. In production it always adds Vary: Accept-Encoding, and compresses when the response carries a compressible Content-Type (text/*, application/json, application/javascript, application/xml, application/manifest+json, application/ld+json, image/svg+xml). Responses that already declare a Content-Encoding pass through untouched. Static framework assets (JS/CSS bundles) and the framework error page also flow through handle, so the same compress() covers them — that’s why other body-touching middleware (auth, etc.) should branch on event.kind === 'asset' if they need to skip framework bundles.
Do NOT keep compress() in front of your CDN if it already brotli/gzips at the edge; instead, drop it or pass methods: ['gzip'] to skip the per-request brotli cost.
noCache
Built-in middleware that defaults Cache-Control: no-cache on page and api responses. Routes that set their own Cache-Control are left untouched, so opt-in caching still works per route.
// file: src/index.ts
import { Mochi, sequence, noCache, compress } from 'mochi-framework';
await Mochi.serve({
handle: sequence(noCache, compress()),
routes,
});asset, fallback, and error events pass through unchanged — framework bundles already get long-lived immutable caching in production. WebSocket upgrades and SSE streams never reach the middleware.