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/...) are read from publicDir on disk in both modes — scanned once at startup, and re-scanned live in development. They 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.shutdownTimeout: Grace period in ms for in-flight requests to finish onSIGTERM/SIGINTbefore connections are force-closed and the process exits. Default:5000in production,0in development.0force-closes immediately. Note that an open WebSocket never drains, so shutdown pins to this full value whenever one is connected — set it no larger than your orchestrator’s kill grace period.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. Scanned from disk at startup in every mode, so it must ship with a production deploy — the build never copies it.mochi-framework buildpicks this value up from yourMochi.serve()call unless--public-diroverrides it.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:[].barrelWarnings: Warning when a dependency drags a large, almost-entirely-tree-shaken module into the build graph (a “barrel import” that slows rebuilds). Fires once per package in dev, and as one grouped summary line in a production build. Default: enabled.falsesilences it;{ ignore: ['pkg'] }suppresses specific packages;{ minBytes }overrides the 50 KB size threshold. SeeDevelopment mode.build: Output controls formochi-framework build; ignored by the runtime.{ resources: false }hides the emitted-resources list (the summary line keeps its asset count). SeeCLI.svelteConfigPath: Path to a Svelte config file. Default:./svelte.config.js. SeeSvelte config.svelteCompiler: Which compiler emits component JS. Default:'svelte'.'rsvelte'uses the Rust compiler and needs the optional@mochi-framework/rsveltepackage; overridable withMOCHI_SVELTE_COMPILER. Seersvelte compiler.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.bun: Escape hatch for rawBun.serve()options Mochi doesn’t surface — e.g.idleTimeout,maxRequestBodySize,reusePort,tls. Spread intoBun.serve();fetch/websocket/routes/errorare framework-owned and throw if set. SeeRaw Bun.serve optionsbelow.
Raw Bun.serve options
bun is spread straight into the underlying Bun.serve(), so any option Mochi doesn’t expose is reachable through it:
Mochi.serve({
port: 3333,
routes,
bun: {
idleTimeout: 30, // seconds; HTTP default is 10, max 255, 0 disables
maxRequestBodySize: 1024 * 1024 * 256,
},
});Bun times a connection out after idleTimeout seconds of inactivity, so a response that stays quiet for longer than the default 10 seconds dies with request timed out after 10 seconds. Mochi already disables the idle timer for page renders and SSE streams (Mochi.sse), so raising it mainly matters for quiet or long-lived Mochi.api responses (chunked streams) and slow request bodies.
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).