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 one MochiServeOptions object. Every field 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 through the compress() middleware.
Asset caching
In production (development: false), prebuilt JS/CSS bundles served from assetPrefix (default /_mochi) get Cache-Control: public, max-age=31536000, immutable. Filenames are content-hashed, so any change yields a new URL. In development the header is omitted so live-reload edits are not pinned in the browser cache. Public-dir files (./public/...) are read from publicDir on disk in both modes and keep Bun’s default static-route headers. To override, mutate response.headers in a handle middleware.
Options reference
port— TCP port. No default, so set it explicitly.hostname— interface to bind. Defaults to Bun’s default (0.0.0.0).development— enables live reload, debug bar, and dev error overlay. Default:true.liveReload— enable the dev-mode live-reload WebSocket. Default: matchesdevelopment. Setfalseto keep the debug bar but skip the socket.shutdownTimeout— grace period (ms) for in-flight requests onSIGTERM/SIGINT. Default:5000in production,0in development.routes—Record<string, MochiRouteValue>of route paths toMochi.page/Mochi.api/Mochi.ws/Mochi.sse.fetch—(req, server) => Responsefallback 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 string. Default: built-in shell. See Custom HTML shell.handle— aHandle(orsequence(...)) wrapping every request. See Middleware.errorPage— component rendered for uncaught page errors and unmatched routes. Default: built-in. See Error handling.handleError—HandleErrorhook run before the error page renders. See Error handling.compressServerIslandProps— deflate server-island props when it reduces size. Default:true.inlineNestedIslands— render nestedmochi:deferislands in-process during an island fetch instead of emitting more client fetches.mochi:defer:visiblechildren keep their own fetch; one call site opts out withmochi:defer={{ inline: false }}. Default:true. See Server islands.logger— built-in request logger. Default:{ enabled: true }.publicDir— directory served as static assets. Default:./public. Scanned from disk at startup in every mode, so it must ship with a production deploy.staticDirs— extra directory trees mounted under a URL prefix. See Static directories.memoryPressure— drain in-memory caches when the OS reports low memory. Default:true; always off in development. See Cache.cron— durable scheduled jobs to start with the server, fromMochi.cron(name, schedule, handler). See Scheduled jobs.cronStorage— where the cron scheduler stores schedules and jobs. Defaults tomemory. See Scheduled jobs.outDir— base directory for build artifacts and dev cache. Default:./.mochi.assetPrefix— URL prefix for framework client assets and the server-island endpoint. Must start with/, must not be/or end with/. Default:/_mochi.additionalWatchPaths— extra dev-mode watcher paths added tosrcandpublic. Default:[].barrelWarnings— warn when a dependency drags a large, tree-shaken module into the build graph. Default: enabled. See Development mode.build— output controls formochi-framework build. The runtime ignores it. See CLI.svelteConfigPath— path to a Svelte config file. Default:./svelte.config.js. See Svelte config.svelteCompiler— which compiler emits component JS. Default:'svelte'.'rsvelte'needs@mochi-framework/rsvelte. See rsvelte.optimize— run the whole-program svelte-shaker pass over.sveltesource before compiling, so the compiler emits less code. Production only, and needs@mochi-framework/svelte-shaker.trueshakes everything;{ enabled, exclude }gives finer control. Default:false. See Svelte Shaker.protection— Cloudflare-style browser verification: unverified clients get an interstitial that auto-solves the captcha proof-of-work and redeems it for a signed clearance cookie. Default: disabled. See Protection Mode.csrf—MochiCsrfOptionsfor the origin-header check. See below.proxy—MochiProxyOptionsfor trusted reverse-proxy headers. See below.hooks/filters— named lifecycle hooks and value filters. See Extensions.warmup— warm the SSR pipeline at startup by invoking every static page route once.boolean | { enabledInProd, enabledInDev }. Default:false. See below.bun— escape hatch for rawBun.serve()options Mochi does not surface —idleTimeout,maxRequestBodySize,reusePort,tls.fetch/websocket/routes/errorare framework-owned and throw if set. See below.
Static directories
Since: 0.10.0 (not released yet): staticDirs ships in the next Mochi release (0.10.0). This section describes the upcoming API.
Mount a directory tree under a URL prefix. Each entry becomes one Bun directory route, so a large tree costs one route rather than one per file, and Content-Type, ETag, If-None-Match, Range, index.html and sendfile streaming all come from Bun.
// file: src/index.ts
await Mochi.serve({
staticDirs: { '/assets': './media' },
routes,
});This is for large or generated trees — a media library, a docs export, a build directory from another tool. For ordinary site assets keep using publicDir, which scans and registers each file individually.
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
The render pipeline 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, right after the server starts listening:
await Mochi.serve({
warmup: true, // warms in production only
routes,
});warmup: true warms in production only. 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 when the batch finishes. Warmup requests carry warmup: true on their request event and log under a WARM label. Routes with parameter segments (/docs/:slug) and * catch-alls are skipped, since they have no single canonical URL.
Detect warmup hits with event.isWarmup (in middleware) and getRequestContext().isWarmup (in serverProps, components, API handlers) to skip side effects that should not fire for synthetic traffic:
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 a form content type) 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 are not checked.
checkOrigin— compareOriginagainst the resolved expected origin. Default:true.trustedOrigins— extra origins to allow. Default:[].
await Mochi.serve({
proxy: { origin: 'https://app.example.com' },
csrf: { trustedOrigins: ['https://embed.partner.com'] },
routes,
});In production, the check refuses every form mutation until proxy.origin (or proxy.hostHeader) is set, so the deployment break is loud. In development the request is allowed through with a [mochi] warning. Routes declaring form actions without either option also warn once at boot — in both modes — so the misconfiguration is visible before deploy, not first discovered as a production 403.
Proxy
proxy tells the framework how to recover the public origin (for the CSRF check) and the real client IP (for getClientAddress()) from forwarded headers.
origin— explicit public origin. Wins over the header options.protocolHeader— forwarded-protocol header ('x-forwarded-proto').hostHeader— forwarded-host header ('x-forwarded-host').portHeader— forwarded-port header ('x-forwarded-port').addressHeader— forwarded client-IP header ('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 ('x-request-id'). SeedsgetRequestContext().requestId.
await Mochi.serve({
proxy: {
origin: 'https://my.site',
addressHeader: 'x-forwarded-for',
xffDepth: 3,
},
routes,
});xffDepth and spoofing
X-Forwarded-For is comma-separated. Each proxy appends the address it saw. The framework reads from the right, skipping xffDepth - 1 trusted proxies, so xffDepth: 3 returns the real client:
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).