SSR framework for Svelte 5 + Bun with islands-based selective hydration
On this page
Serve options
Mochi.serve(options) boots the Bun server and registers routes. Pass a single MochiServeOptions object — every field below is optional except routes.
// file: src/index.ts
import { Mochi } from 'mochi-framework';
await Mochi.serve({
port: 3333,
routes: {
'/': Mochi.page('./src/Home.svelte'),
},
});Response compression is opt-in via the compress() middleware — add it to handle: sequence(...) to negotiate brotli or gzip per client.
Asset caching
In production (development: false), prebuilt JS/CSS bundles served from assetPrefix (default /_mochi) get Cache-Control: public, max-age=31536000, immutable automatically. Filenames are content-hashed, so any change yields a new URL — there’s nothing to invalidate. In development the header is omitted so live-reload edits aren’t pinned in the browser cache. Public-dir files (./public/...) keep Bun’s default static-route headers; their URLs are stable, so don’t mark them immutable. To override, mutate response.headers in a handle middleware.
Options reference
port: TCP port to listen on. No default — set it explicitly.hostname: Interface to bind. Defaults to Bun’s default (0.0.0.0).development: Enables live reload, debug bar, and the dev error overlay. Default:true.liveReload: Enable the dev-mode live-reload WebSocket (/__mochi_live_reload+ themochi-live-reloadweb component). Default: matchesdevelopment. Set tofalseto keep the debug bar but skip the WS — useful behind a proxy where the socket is flaky.routes:Record<string, MochiRouteValue>of route paths toMochi.page/Mochi.api/Mochi.ws/Mochi.sseregistrations.fetch:(req, server) => Responsefallback handler invoked when no route matches. Default: built-in 404.manifest: Path to a prebuilt manifest JSON. Default:<outDir>/manifest.json.htmlShell: Path to an.htmltemplate or an inline template string. Default: built-in shell. SeeCustom HTML shell.handle: AHandle(orsequence(...)of them) that wraps every request. SeeMiddleware (hooks).errorPage: Path to a Svelte component rendered for uncaught page errors and unmatched routes. Default: built-in minimal error page. SeeError handling.handleError:HandleErrorhook invoked before the error page renders; may override status/message or return aResponse. SeeError handling.compressServerIslandProps: Deflate-compress server-island props when it reduces size. Default:true.logger: Built-in request logger. Default:{ enabled: true }. Pass{ enabled: false }to disable, or overrideslowThreshold/verySlowThreshold.publicDir: Directory served as static assets (cwd-relative). Default:./public.outDir: Base directory for build artifacts and dev cache (cwd-relative). Default:./.mochi. Production writes here directly; development nests under<outDir>/devso a dev run can’t collide with a production build (and is wiped clean on every dev startup).assetPrefix: URL prefix for framework client assets and the server-island endpoint. Must start with/, must not be/, must not end with/, must not contain whitespace or... Default:/_mochi.additionalWatchPaths: Extra dev-mode watcher paths added to the defaultssrcandpublic. Default:[].svelteConfigPath: Path to a Svelte config file. Default:./svelte.config.js. SeeSvelte config.csrf:MochiCsrfOptionscontrolling the origin-header check. SeeCSRFbelow.proxy:MochiProxyOptionsdescribing trusted reverse-proxy headers. SeeProxybelow.hooks:MochiHooksmap of named lifecycle hooks. SeeExtensions (hooks & filters).filters:MochiFiltersmap of named value-replacement filters. SeeExtensions (hooks & filters).warmup: Warm the SSR pipeline at startup by invoking every static page route once.boolean | { enabledInProd: boolean; enabledInDev: boolean }.truewarms in production only; pass the object form for per-mode control. Default:false. SeeRoute warmupbelow.
Route warmup
Components are compiled at startup, but the render pipeline — serverProps, Svelte SSR, HTML shell assembly — stays cold until a route is first visited, so the first request to each page pays a one-time penalty. Set warmup: true to invoke every static page route once, in the background, immediately after the server starts listening:
await Mochi.serve({
warmup: true, // warms in production only
routes,
});warmup: true warms in production only — dev restarts are frequent, and the extra render burst on every reload isn’t worth it. For per-mode control, pass an object:
await Mochi.serve({
warmup: { enabledInProd: true, enabledInDev: false },
routes,
});Warmup is fire-and-forget — the server accepts real traffic immediately, and a warmup:complete event fires once the batch finishes. Routes are warmed one at a time so each request event reports its own render duration rather than a cumulative figure. Warmup requests carry warmup: true on their request event and log under a WARM label instead of GET, so they’re easy to tell apart from real traffic. Each warmed route runs through its real handler (middleware included) as an anonymous GET; a route that throws or returns a 5xx is counted in warmup:complete’s errorCount, but the SSR module stays warm regardless.
Routes that aren’t fully static — parameter segments (/docs/:slug) and * catch-alls — are skipped, since they have no single canonical URL to warm.
Detect warmup hits from your own code to skip side effects that shouldn’t fire for synthetic traffic. Both event.isWarmup (in middleware) and getRequestContext().isWarmup (in serverProps, components, API handlers) are true during warmup:
const analytics: Handle = async ({ event, resolve }) => {
if (!event.isWarmup) track(event.url.pathname); // skip warmup hits
return resolve(event);
};CSRF
csrf gates state-mutating form submissions (POST / PUT / PATCH / DELETE with application/x-www-form-urlencoded, multipart/form-data, or text/plain) against an origin-header check; the request’s Origin must match the expected origin or appear in csrf.trustedOrigins. JSON endpoints rely on the browser’s CORS preflight and aren’t checked.
checkOrigin: CompareOriginagainst the resolved expected origin. Default:true.trustedOrigins: Extra origins to allow even when they don’t match. Default:[].
await Mochi.serve({
proxy: {
origin: 'https://app.example.com',
},
csrf: {
trustedOrigins: ['https://embed.partner.com'],
// checkOrigin: false, // disable the check entirely
},
routes,
});In production the check refuses every form mutation until proxy.origin (or proxy.hostHeader) is set, so the deployment break is loud rather than silent. In development the same request is allowed through with a [mochi] warning so local work isn’t blocked.
Proxy
proxy tells the framework how to recover the public origin (used by the CSRF check) and the real client IP (returned by getClientAddress() on the request context) from forwarded headers. Behind a load balancer, CDN, or tunnel, the connection Bun sees is the proxy, not the client.
origin: Explicit public origin (e.g.'https://my.site'). Wins over the header options.protocolHeader: Forwarded-protocol header (typically'x-forwarded-proto').hostHeader: Forwarded-host header (typically'x-forwarded-host').portHeader: Forwarded-port header (typically'x-forwarded-port'). Only needed when the public port differs.addressHeader: Forwarded client-IP header (e.g.'true-client-ip','x-forwarded-for').xffDepth: Number of trusted proxies in front of the server whenaddressHeaderis'x-forwarded-for'. Default:1.requestIdHeader: Forwarded correlation-id header (typically'x-request-id'). SeedsgetRequestContext().requestIdwhen set on the inbound request.
await Mochi.serve({
proxy: {
// Either pin the public origin…
origin: 'https://my.site',
// …or derive it from forwarded headers.
protocolHeader: 'x-forwarded-proto',
hostHeader: 'x-forwarded-host',
portHeader: 'x-forwarded-port',
// Client IP for getClientAddress():
addressHeader: 'x-forwarded-for',
xffDepth: 3, // 3 trusted proxies in front of the server
},
routes,
});xffDepth and spoofing
X-Forwarded-For is comma-separated — each proxy appends the address it saw. With three trusted proxies and no spoofing:
client, proxy1, proxy2The framework reads from the right, skipping xffDepth - 1 trusted proxies, so xffDepth: 3 returns client. Reading from the right blocks spoofing: a client setting its own X-Forwarded-For gets pushed leftward by each trusted proxy.
spoofed, client, proxy1, proxy2 # xffDepth: 3 → "client" (spoofed entry ignored)getClientAddress()
import { getRequestContext, Mochi } from 'mochi-framework';
export const handler = Mochi.api(() => {
const ip = getRequestContext().getClientAddress();
return Response.json({ ip });
});Without proxy.addressHeader, this returns Bun’s connecting remoteAddress (or null if unavailable).