--- title: 'Welcome' slug: intro --- # mochi Mochi is a SSR-focused framework for [Svelte 5](https://svelte.dev/) and [Bun](https://bun.sh/). It has a strong focus on performance and uses an islands architecture. This means you can selectively hydrate the Svelte components you want to be interactive, and leave the rest as performant HTML without having to ship the runtime for those components to the client. Less JavaScript, faster pages, and happier users are ahead! > **Early prototype.** Only use in production if you are brave! --- title: 'Setup' slug: setup --- ## Setup ```sh bun install bun run dev # Development mode with live reload bun run start # Production mode ``` **What to expect in the current state** There are many things that aren't implemented yet. You'll find a non-exhaustive list below: - HMR always does full page reloads - No SCSS/SASS support - No HTTP streaming (see this page for why you probably don't need it) --- title: 'Scripts' slug: scripts --- ## Scripts | Script | Command | Description | | --------- | ------------------- | ------------------------------- | | dev | `bun run dev` | Start dev server (live reload) | | start | `bun run start` | Start production server | | build | `bun run build` | Pre-build islands for prod | | typecheck | `bun run typecheck` | Type-check with tsc | | lint | `bun run lint` | Lint with ESLint | | lint:fix | `bun run lint:fix` | Lint and auto-fix | | format | `bun run format` | Format with Prettier | | clean | `bun run clean` | Remove `.mochi` build artifacts | --- title: 'Defining routes' slug: defining-routes --- ## Defining routes Routes map URL paths to page entries, API handlers, WebSocket endpoints, or SSE streams: ```ts import { Mochi } from './mochi-framework/Mochi'; const routes = { '/': Mochi.page('./src/Home.svelte'), '/about': Mochi.page('./src/About.svelte', { serverProps: { title: 'About' } }), '/health': Mochi.api(({ method }) => Response.json({ status: 'ok' })), '/ws/chat': Mochi.ws({ message(ws, msg) { ws.send(msg); }, }), '/sse/time': Mochi.sse((stream) => { stream.send('hello'); }), }; await Mochi.serve({ port: 3333, development: process.env.MODE === 'development', routes, }); ``` --- title: 'Progressively enhancing forms with enhance' slug: progressively-enhancing-forms-with-enhance --- ## Progressively enhancing forms with enhance `enhance` is a Svelte attachment that progressively enhances a `
`. The same server-side action handler runs whether JavaScript is available or not — but with `{@attach enhance(...)}`, the client submits over `fetch`, the server returns a JSON `MochiEnhanceResult` instead of a re-rendered HTML page, and there's no full-page reload. ```svelte
``` The form must live inside a hydrated island (eg: `mochi:hydrate`, `mochi:hydrate:visible`). If hydration is skipped, the attachment never runs and the form falls back to a native HTML POST automatically — that's the progressive-enhancement contract. > Attachments require Svelte 5.29+. `enhance` is a factory: even with no options, call it as `{@attach enhance()}`. ### Wire format `enhance` adds two headers to the POST: `Accept: application/json` and `x-mochi-action: true`. The server detects these and responds with one of four shapes: ```ts type MochiEnhanceResult = | { type: 'success'; status: number; data?: unknown } | { type: 'failure'; status: number; data?: unknown } | { type: 'redirect'; status: number; location: string } | { type: 'error'; status?: number; error: unknown }; ``` The HTTP status is `200` for success, failure, and redirect — the body's `status` field carries the action's status. For `error`, the HTTP status matches the error code. `data` is encoded with [devalue](https://www.npmjs.com/package/devalue) so `Date`, `Map`, `Set`, `BigInt`, and cyclic references survive the wire. ### Default fallback Without a callback, `enhance` runs a minimal default for each result type: | `result.type` | Default | | ------------- | ------------------------------------------------- | | `success` | `form.reset()` | | `failure` | nothing (provide a callback to update the UI) | | `redirect` | `window.location.assign(result.location)` | | `error` | `console.error('[mochi] enhance:', result.error)` | **Mochi's default fallback is intentionally lean.** Unlike SvelteKit's `use:enhance`, Mochi has no client-side `page.form` store, no `goto`, and no `invalidateAll`. The framework can't auto-update component props or re-run server data after a submission. For interactive feedback on `failure` (or anything more involved than a redirect), provide a `submit` callback. When the same component renders both as a hydrated island (where `enhance` will fire) and as a plain SSR-only child (where it won't), read the [auto-injected `isHydratable` prop](/docs/environment-constants#auto-injected-props) to skip the SSR `form`-prop peek when the client will take over. ### Submit callback Pass a function as the argument. It runs once per submit and may return a result handler that fully replaces the default fallback: ```svelte
``` The result handler receives `{ result, formElement, formData, action, update }`. Calling `update({ reset?: boolean })` re-invokes the default fallback — useful when you only want to layer extra behavior on top of the framework default. ### onPending Instead of managing a `pending` flag inside the submit function, pass an options object with `onPending`. The callback fires with `true` immediately before the fetch and `false` once the result handler has settled (or when the submission is cancelled): ```svelte
``` `onPending` is guaranteed to fire `false` in a `finally` block, so it resets even if the fetch throws or the abort signal fires. ### Cancelling The `submit` callback receives `cancel` and `controller`: - `cancel()` — bail out before the `fetch` is issued. No callback runs. - `controller.abort()` — cancel an in-flight request. The `AbortError` is silently swallowed. ### Server-side No server changes are needed beyond declaring the action. The same `Mochi.page(path, { actions })` definition serves both the no-JS HTML POST flow and the enhanced JSON flow: ```ts import { fail, redirect, success } from 'mochi-framework'; Mochi.page('./Login.svelte', { actions: { default: ({ formData, cookies }) => { const username = String(formData.get('username') ?? ''); if (!username) return fail(400, { error: 'Username required' }); return success({ username }); }, }, }); ``` Returning a `Response` directly from an action bypasses the JSON envelope on enhanced submissions — treat that path as an escape hatch. **Wrap data in `success()` to round-trip it to the client.** If an action returns a plain object (e.g. `return { username }`) instead of `return success({ username })`, the enhanced path replaces the data with `{}`. This matches the non-enhanced behavior and is not a regression, but it means your result handler will receive an empty `data` object. Always use `success()` when the client needs the returned data. ### deserialize `deserialize(text)` decodes a raw `MochiEnhanceResult` envelope. Use it when rolling your own `onsubmit` instead of `{@attach enhance(...)}`: ```svelte ``` ### When to use enhance Reach for `enhance` when the action's outcome should update UI without a navigation flicker — interactive forms, optimistic patterns, inline validation messages. Stick with a plain `
` when the action ends in a redirect anyway and the JS bundle isn't worth shipping. --- title: 'Selective hydration with mochi:hydrate' slug: selective-hydration --- ## Selective hydration with `mochi:hydrate` Components render server-side by default and ship zero JavaScript. Add `mochi:hydrate` to opt a component into client-side hydration: ```svelte ``` Props are serialized into the HTML so they're available during hydration. Hydration is all-or-nothing per island — the entire component subtree hydrates together. --- title: 'Lazy hydration with mochi:hydrate:visible' slug: lazy-hydration --- ## Lazy hydration with `mochi:hydrate:visible` Defer hydration until a component scrolls into view using `mochi:hydrate:visible`: ```svelte ``` The component is server-rendered, but its JavaScript and CSS are only loaded when it becomes visible. Pass `rootMargin` to start loading before the component enters the viewport. **Note:** Visible islands require JS to be fully styled — their CSS is not included in the initial page load. --- title: 'Server islands with mochi:defer' slug: server-islands --- ## Server islands with `mochi:defer` Server islands allow you to defer rendering of dynamic or personalized components until after the initial page load. The page renders immediately with fallback content, then each server island fetches its own HTML from a dedicated endpoint. This is useful for personalized content (user avatars, cart counts) that shouldn't block the rest of the page from being aggressively cached. ```svelte
Loading...
``` Server island components are normal Svelte components that run on the server. They have full access to the request context (cookies, headers) via `getRequestContext()`: ```svelte

Welcome back, {userName}!

``` ### How it works 1. During initial SSR, the component is **not rendered** — only the fallback content is emitted inside a `` custom element 2. Props are HMAC-signed and passed as a query parameter to prevent tampering 3. The browser fetches the component's HTML from `/_mochi/island/{ComponentName}?p={signedProps}` (the `/_mochi` prefix is configurable via `assetPrefix`) 4. Cookies are forwarded automatically (same-origin fetch), so the component can access the user's session 5. The returned HTML replaces the fallback content ### Combining with hydration Server islands can also be hydrated for client-side interactivity: ```svelte ``` ### Props Props are serialized with `devalue` — see [Passing props to islands](island-props) for the full list of supported types. Server islands additionally HMAC-sign the payload and pass it as a query parameter; if the signed props exceed URL length limits (~1800 bytes), a warning is emitted. ### Prop deduplication During SSR each island registers its serialized props in a per-request map keyed by the exact `devalue` output, then carries a small `props-ref="mochi-props-"` pointer to the matching ` ``` Two islands whose props serialize byte-identically share a single block — this reduces bytes over the wire when two islands share the same props. Server islands (`mochi:defer`) are unaffected: their `signed-props` are HMAC-signed and stay inline so the signature ties to the exact attribute value the client sees. ### Encryption key Props are HMAC-signed with a random key generated at server startup. For multi-instance deployments (rolling deploys, CDN caching), set the `MOCHI_KEY` environment variable to a constant base64url-encoded 32-byte key so all instances share the same signing key. --- title: 'Passing props to islands' slug: island-props --- ## Passing props to islands Any component marked with `mochi:hydrate`, `mochi:hydrate:visible`, or `mochi:defer` becomes an island, and its props have to cross the SSR → client boundary. Mochi serializes them with [`devalue`](https://github.com/Rich-Harris/devalue), which preserves richer types than `JSON.stringify`. ```svelte ``` ### How it works 1. During SSR, `devalue.stringify` serializes the props object. 2. The serialized string is placed on the `` custom element as a `props` attribute. When multiple islands on the same page share an identical payload, it's hoisted into a shared ` ``` See the [Forms demo](/demos/login) for a side-by-side comparison of hydrated and SSR-only render paths. --- title: 'API routes' slug: api-routes --- ## API routes `Mochi.api()` creates JSON API endpoints. The handler receives a `MochiApiEvent` with `method`, `request`, `url`, and `locals`: ```ts import { error } from "./mochi-framework/utils"; "/health": Mochi.api(({ method }) => Response.json({ status: "ok", method }), ), "/add": Mochi.api(async ({ method, request }) => { if (method !== "POST") error(405, "Method Not Allowed"); const { a, b } = await request.json(); return Response.json({ result: a + b }); }), ``` ### Error responses API routes return a standard JSON envelope for all errors — they never render the HTML error page, and `handleError` is not called for them. Use `apiError(status, message)` to build the envelope response: ```ts import { apiError } from 'mochi-framework'; Mochi.api(async ({ request }) => { const body = await request.json().catch(() => null); if (!body) return apiError(400, 'Invalid JSON'); return Response.json({ ok: true }); }); // bad body → 400 { "error": { "message": "Invalid JSON", "status": 400 } } ``` `apiError` returns a `Response` with `Content-Type: application/json` and the HTTP status set to match. Prefer it over throwing when the error is part of the route's normal control flow. **Thrown `MochiHttpError`** — same envelope. Use `error(status, message)` from `mochi-framework` to throw one from deep inside helper code where returning is awkward: ```ts import { error } from 'mochi-framework'; error(404, 'Not found'); // → 404 { "error": { "message": "Not found", "status": 404 } } ``` **Any other uncaught throw** — returned as `500 Internal Server Error` with a generic message. The original error (including stack) is logged server-side via `log.error` with the method and path; it is **not** leaked to the client. Throw `MochiHttpError` (or return `apiError(…)`) explicitly when you want a specific status or message to reach the caller. ```ts Mochi.api(async () => { await db.query('SELECT …'); // throws ConnectionError return Response.json({ ok: true }); }); // → 500 { "error": { "message": "Internal Server Error", "status": 500 } } // (real error + stack logged to stderr) ``` --- title: 'WebSocket routes' slug: websocket-routes --- ## WebSocket routes `Mochi.ws()` creates WebSocket endpoints with `open`, `message`, `close`, and `drain` handlers. Bun's pub/sub is available via `ws.subscribe()` and `ws.publish()`: ```ts "/ws/chat": Mochi.ws({ open(ws) { ws.subscribe("chat"); }, message(ws, message) { ws.publish("chat", String(message)); ws.send(String(message)); }, close(ws) { ws.unsubscribe("chat"); }, }), ``` An optional `upgrade` handler can validate the connection and attach user data: ```ts Mochi.ws<{ userId: string }>({ upgrade(req) { const userId = req.headers.get('x-user-id'); if (!userId) return false; // reject return { userId }; // attached to ws.data.user }, message(ws, msg) { console.log(ws.data.user.userId, msg); }, }); ``` --- title: 'Server-Sent Events' slug: server-sent-events --- ## Server-Sent Events `Mochi.sse()` creates SSE streams. The handler receives a stream with `send()`, `close()`, and `onClose()`: ```ts "/sse/time": Mochi.sse((stream) => { stream.send(new Date().toISOString()); const interval = setInterval(() => { stream.send(new Date().toISOString()); }, 1000); stream.onClose(() => clearInterval(interval)); }), ``` `send()` accepts an optional second argument with `event` and `id` fields for named events. --- title: 'Middleware (hooks)' slug: middleware --- ## Middleware (hooks) Middleware uses SvelteKit-style `Handle` functions. Compose multiple handlers with `sequence()`: ```ts import { sequence } from './mochi-framework/hooks'; import type { Handle } from './mochi-framework/hooks'; const auth: Handle = async ({ event, resolve }) => { if (!event.request.headers.get('Authorization')) { return new Response('Unauthorized', { status: 401 }); } return resolve(event); }; const logging: Handle = async ({ event, resolve }) => { console.log('→', event.url.pathname); const response = await resolve(event); console.log('←', response.status); return response; }; await Mochi.serve({ handle: sequence(auth, logging), routes, }); ``` `event.locals` is a per-request object for passing data between middleware layers. `resolve()` accepts options for post-processing: - **`transformPageChunk({ html })`** — transform the HTML response before sending - **`filterResponseHeaders(name, value)`** — filter which headers are included --- title: 'Transforming HTML with transformPageChunk' slug: transform-page-chunk --- ## Transforming HTML with `transformPageChunk` `transformPageChunk` is a resolve option that lets middleware transform the final HTML before it's sent to the client. It only runs on `text/html` responses. ```ts import type { Handle } from 'mochi-framework/hooks'; const greeting: Handle = async ({ event, resolve }) => { return resolve(event, { transformPageChunk({ html }) { return html.replace('{{app.greeting}}', 'Welcome to Mochi!'); }, }); }; ``` Add a `{{app.greeting}}` placeholder anywhere in your HTML shell, and the middleware will replace it on every page response: ```html
{{app.greeting}}
{{mochi.body}} ``` The callback receives `{ html, done }` and can return a `string`, `undefined`, or a `Promise` — so async transforms work too: ```ts const inject: Handle = async ({ event, resolve }) => { return resolve(event, { async transformPageChunk({ html }) { const banner = await fetchBannerMessage(); return html.replace('{{app.banner}}', banner); }, }); }; ``` When multiple handlers in a `sequence()` use `transformPageChunk`, they run in reverse order — inner handlers transform first, then outer handlers receive the result. --- title: 'Error handling' slug: error-handling --- ## Error handling Mochi renders an error page for uncaught page-render errors, `error(status, ...)` thrown from `serverProps` or actions, and any request that doesn't match a route. API routes are unaffected — they return a JSON envelope instead; see [API routes → Error responses](#error-responses) above. Configure an error page via `errorPage` on `Mochi.serve()`. If omitted, a minimal built-in component is used. ```ts await Mochi.serve({ errorPage: './src/Error.svelte', handleError: ({ error, status, event }) => { if (status >= 500) console.error('[app]', event.url.pathname, error); }, routes, }); ``` The error component receives a single `error` prop: ```svelte

{error.status}

{error.message}

{#if error.stack}
{error.stack}
{/if} ``` | Field | Description | | --------- | ----------------------------------------------------------------- | | `status` | HTTP status — 404, 500, or whatever was passed to `error()` | | `message` | Human-readable message — safe to render | | `stack` | Stack trace. Only populated when `development: true`, else absent | ### `handleError` hook Fires whenever the error page is about to render — uncaught page/form render errors, unmatched routes, unknown form actions, and malformed form bodies. Use it to log, forward to error tracking, or sanitize the message the user sees. `error` is `null` when the condition didn't originate from a throw (e.g. unmatched routes, unknown form actions). Inspect it before forwarding so benign 4xx cases don't page on-call. Return one of: - `{ status, message }` — override either field passed to the error component - a `Response` — short-circuit rendering entirely (useful for redirects or custom responses) - nothing — keep the defaults ```ts import type { HandleError } from 'mochi-framework'; const handleError: HandleError = ({ error, event, status, message }) => { if (error) tracker.capture(error, { path: event.url.pathname }); if (status === 404) return Response.redirect(new URL('/', event.url), 302); if (status >= 500) return { status, message: 'Something went wrong.' }; }; ``` If the hook itself throws, Mochi logs the secondary error and renders the error page with the original `status`/`message`. Not called for API-route errors. ### Fallback behavior If the user's `errorPage` component itself throws during render, Mochi returns a plain-text response that mentions both the original error and the secondary render failure — the error page cannot crash the server. --- title: 'Error boundaries' slug: error-boundaries --- ## Error boundaries Every `mochi:hydrate` and `mochi:hydrate:visible` island is auto-wrapped in ``. A throw in one island no longer takes down the whole page render — the failed island is replaced by a small marker (or hidden in production) and the rest of the page continues. No opt-in, no configuration. ### What's wrapped - `mochi:hydrate` — wrapped. - `mochi:hydrate:visible` — wrapped. - `mochi:defer` — handled by the server-island endpoint instead (see below). - Top-level page render throws — go to the configured `errorPage`, not to a boundary. Mochi does not insert a page-level boundary; if you want a section to degrade gracefully, author `` yourself. ### What gets caught - Synchronous SSR throws inside the island. - Async SSR throws (`await Promise.reject(...)` in a top-level ` ## Cache `MochiCache` wraps [`stale-while-revalidate-cache`](https://www.npmjs.com/package/stale-while-revalidate-cache) for caching server-side data — typically slow upstream API calls. Construct once at module scope and share the instance across requests. ```ts // src/lib/cache.ts import { MochiCache } from 'mochi-framework'; export const pokemonCache = new MochiCache({ minTimeToStale: 10_000, // serve fresh for 10s maxTimeToLive: 300_000, // hard expiry at 5min }); ``` Use it from a page or API route: ```svelte ``` **Caches are shared in-process.** Every request reads from the same `Map`, so a key like `cart:current` will leak one user's data to another. Prefix per-user keys with the user id — e.g. `` `cart:${userId}` `` — and do the same for any other request-scoped dimension (tenant, locale, role). ### Behaviour - **Fresh** (within `minTimeToStale`): cached value returned, no fetch. - **Stale** (between `minTimeToStale` and `maxTimeToLive`): cached value returned immediately, fetch runs in the background and updates the cache. - **Expired** (past `maxTimeToLive`): fetch runs synchronously and the caller waits. ### API | Method | Returns | | -------------------------- | ---------------------------- | | `fetch(key, fn)` | `Promise` | | `fetchWithStatus(key, fn)` | `Promise<{ value, status }>` | | `delete(key)` | `Promise` | `status` is `'fresh' \| 'stale' \| 'expired' \| 'miss'`. ### Options `MochiCacheOptions` extends the upstream `Config` (so `retry`, `retryDelay`, `serialize`, `deserialize` and any future options pass through). Mochi-specific defaults: | Option | Default | | ---------------- | ----------------- | | `minTimeToStale` | `5_000` (5s) | | `maxTimeToLive` | `600_000` (10min) | | `storage` | in-memory `Map` | For multi-process or persistent caching, pass a custom `storage` that implements `getItem` / `setItem` / `removeItem` (e.g. Redis, SQLite via `bun:sqlite`). ### Subscribing to cache events `MochiCache` emits two events on `mochiEvents`: | Event | Payload | When | | ------------------ | ----------------- | --------------------------------------------------- | | `cache:read` | `{ key, status }` | Every cache lookup, regardless of which method ran. | | `cache:revalidate` | `{ key }` | A background refetch starts (stale read). | `status` is `'fresh' \| 'stale' \| 'expired' \| 'miss'`. Use `mochiEvents.setHandler` to attach a custom subscriber — it replaces a prior handler under the same name, so dev re-imports don't pile up listeners: ```ts import { mochiEvents } from 'mochi-framework'; mochiEvents.setHandler('metrics:cache-read', 'cache:read', ({ key, status }) => { metrics.increment(`cache.${status}`, { key }); }); ``` `logger()` already prints `cache:revalidate` lines by default. Pass `{ cache: 'verbose' }` to also print every read, or `{ cache: false }` to silence cache logging: ```ts import { logger } from 'mochi-framework'; logger({ cache: 'verbose' }); ``` See the [Cache Events demo](/demos/cache-events) for a worked example that pipes events into an in-memory ring buffer and renders them on the page. ### Server-only `MochiCache` lives on the server. Importing it into a hydratable island throws — caches are shared per-process state and don't make sense in the browser. Construct cache instances in `.ts` modules or page-route scripts, never inside a `mochi:hydrate` component. --- title: 'Events' slug: events --- ## Events Mochi emits lifecycle events through a process-wide [`mitt`](https://www.npmjs.com/package/mitt) emitter exported as `mochiEvents`. Subscribe from application code to feed metrics, audit logs, custom log destinations, or anything else that wants a structured view of what the server is doing. Names use a `namespace:action` convention. Every event key is in the typed `MochiEventMap`, so handlers receive a precise payload type without casts. Events: - [`request`](#request) — every HTTP request (page or API) - [`ws:open`](#wsopen), [`ws:message`](#wsmessage), [`ws:close`](#wsclose) — WebSocket lifecycle - [`sse:open`](#sseopen), [`sse:message`](#ssemessage), [`sse:close`](#sseclose) — Server-Sent Events lifecycle - [`server:start`](#serverstart), [`server:stop`](#serverstop) — server lifecycle - [`error`](#error) — page/api/action handler threw, response was an error page or `apiError` - [`action:invoke`](#actioninvoke), [`action:complete`](#actioncomplete) — form action lifecycle - [`compile:start`](#compilestart), [`compile:complete`](#compilecomplete), [`compile:error`](#compileerror) — Svelte SSR build - [`island:error`](#islanderror) — a hydratable, server, or client-hydrate island errored - [`file:change`](#filechange) — dev-only file watcher - `cache:read`, `cache:revalidate` — see [Subscribing to cache events](/docs/cache#subscribing-to-cache-events) ### Subscribing Two patterns: ```ts import { mochiEvents } from 'mochi-framework'; mochiEvents.on('request', ({ method, path, status, duration }) => { metrics.timing('http.request', duration, { method, path, status }); }); ``` `.on()` is the standard mitt API. Handlers are sync — to do async work, kick it off without awaiting (e.g. fire-and-forget to your metrics client) so you don't stall the next emitter. For HMR-safe subscribers — i.e. modules that may be re-imported by the dev SSR compile cache — use `setHandler` instead. It registers under a name and replaces any prior handler under the same name, so re-imports don't pile up listeners: ```ts import { mochiEvents } from 'mochi-framework'; mochiEvents.setHandler('metrics:request', 'request', ({ status, duration }) => { metrics.timing('http.request', duration, { status }); }); ``` Namespace the name (`metrics:request`, not `request`) so unrelated subsystems don't silently evict each other. Don't mix `setHandler` with `.off()` for the same name — the name table is maintained only by `setHandler`. ### Correlating events for one request Every HTTP request gets a stable `requestId` carried on `request`, `error`, `action:invoke`, and `action:complete`. Use it to stitch together a full picture (e.g. trace a 500: `error` payload + the matching `request` payload). The same id is exposed on the request context: ```ts import { getRequestContext } from 'mochi-framework'; const { requestId } = getRequestContext(); ``` By default the framework generates the id with `nanoid`. To pick up an upstream id from a trusted reverse proxy instead, set `proxy.requestIdHeader`: ```ts Mochi.serve({ proxy: { requestIdHeader: 'X-Request-Id' }, // … }); ``` Only enable this behind a proxy you control — clients can spoof any header, so trusting a public-internet `X-Request-Id` lets attackers smear log lines for unrelated requests together. ### Skipping work when nobody listens If you `emit` your own custom events whose payload is expensive to build (large allocations, stack capture, structured log mapping), wrap the construction in `hasSubscribers(name)`: ```ts import { hasSubscribers, mochiEvents } from 'mochi-framework'; if (hasSubscribers('compile:error')) { mochiEvents.emit('compile:error', expensivePayload()); } ``` The built-in events emit unconditionally — their payloads are cheap, and the cost of an extra object literal per request is below noise. Reach for `hasSubscribers` only when payload construction would dominate. ### Event reference #### `request` Fires after an HTTP response is finalized — once per request, including CSRF rejects. Covers both `Mochi.page()` and `Mochi.api()` routes (distinguished by `kind`). | Field | Type | Notes | | ----------- | ----------------- | --------------------------- | | `requestId` | `string` | correlation id (see above) | | `kind` | `'page' \| 'api'` | which route type handled it | | `method` | `string` | HTTP method | | `path` | `string` | URL pathname | | `status` | `number` | response status code | | `duration` | `number` | wall-clock ms, end-to-end | ```ts mochiEvents.on('request', ({ kind, method, path, status, duration }) => { if (status >= 500) alerts.fire({ kind, method, path, status, duration }); }); ``` #### `ws:open` Fires after a successful WebSocket upgrade. | Field | Type | Notes | | ---------- | -------- | ----------------------------------- | | `path` | `string` | URL pathname of the upgrade request | | `duration` | `number` | ms spent in the upgrade handler | ```ts mochiEvents.on('ws:open', ({ path }) => { metrics.increment('ws.open', { path }); }); ``` #### `ws:message` Fires for every inbound WebSocket frame, after the user `message` handler returns. | Field | Type | Notes | | ------ | -------------------- | ----------------------------- | | `path` | `string` | URL pathname | | `size` | `number` | bytes (text length or buffer) | | `type` | `'text' \| 'binary'` | frame kind | ```ts mochiEvents.on('ws:message', ({ path, size, type }) => { metrics.histogram('ws.message.size', size, { path, type }); }); ``` #### `ws:close` Fires when a WebSocket connection closes. | Field | Type | Notes | | ---------- | -------- | --------------------------- | | `path` | `string` | URL pathname | | `duration` | `number` | ms the socket was open | | `code` | `number` | WebSocket close code | | `reason` | `string` | close reason (may be empty) | ```ts mochiEvents.on('ws:close', ({ path, code, duration }) => { metrics.timing('ws.session', duration, { path, code }); }); ``` #### `sse:open` Fires when an SSE stream starts (the `ReadableStream` is pulled by the runtime). | Field | Type | Notes | | ------ | -------- | ------------ | | `path` | `string` | URL pathname | ```ts mochiEvents.on('sse:open', ({ path }) => { metrics.increment('sse.open', { path }); }); ``` #### `sse:message` Fires per `stream.send()` inside an SSE handler. | Field | Type | Notes | | ------- | --------------------- | --------------------------------------- | | `path` | `string` | URL pathname | | `size` | `number` | bytes written for the data line | | `event` | `string \| undefined` | optional named event passed to `send()` | ```ts mochiEvents.on('sse:message', ({ path, size, event }) => { metrics.histogram('sse.message.size', size, { path, event: event ?? 'message' }); }); ``` #### `sse:close` Fires when the SSE stream closes (client disconnect or explicit close). | Field | Type | Notes | | ---------- | -------- | ---------------------- | | `path` | `string` | URL pathname | | `duration` | `number` | ms the stream was open | ```ts mochiEvents.on('sse:close', ({ path, duration }) => { metrics.timing('sse.session', duration, { path }); }); ``` #### `server:start` Fires once after `Bun.serve()` binds the listening socket. Includes a count of routes by kind. | Field | Type | Notes | | ------------- | -------------------------------- | --------------------- | | `port` | `number` | bound port | | `hostname` | `string \| undefined` | bound hostname if any | | `development` | `boolean` | dev or prod mode | | `routes` | `{ page, api, ws, sse: number }` | route counts by kind | ```ts mochiEvents.on('server:start', ({ port, routes }) => { log.info(`listening on ${port} — ${routes.page} pages, ${routes.api} APIs`); }); ``` #### `server:stop` Fires when the server is shutting down via `SIGTERM`/`SIGINT`, after the `mochi:shutdown` hook has run. | Field | Type | Notes | | -------- | ---------------------------- | --------------------------- | | `reason` | `'signal'` | what initiated the shutdown | | `signal` | `'SIGTERM' \| 'SIGINT' \| -` | the signal received | ```ts mochiEvents.on('server:stop', ({ reason, signal }) => { metrics.flush(); // drain before the process exits }); ``` #### `error` Fires when a page, API, or form action handler throws and the framework returns an error response. Useful for routing exceptions to Sentry/Rollbar/Datadog without monkey-patching. | Field | Type | Notes | | ------------ | ----------------------------- | ------------------------------------------------- | | `requestId` | `string` | correlates with the matching `request` event | | `kind` | `'page' \| 'api' \| 'action'` | which handler threw | | `path` | `string` | URL pathname + search | | `method` | `string` | HTTP method | | `status` | `number` | final response status | | `message` | `string` | error message | | `stack` | `string \| undefined` | stack trace, populated in dev only | | `actionName` | `string \| undefined` | form action name; present only when `kind=action` | ```ts mochiEvents.on('error', ({ kind, path, status, message, stack }) => { Sentry.captureException(new Error(message), { tags: { kind, path, status }, contexts: { stack } }); }); ``` #### `action:invoke` Fires immediately before a form action handler runs. Pairs with `action:complete` via `requestId`. | Field | Type | Notes | | ------------ | -------- | ------------------------------------ | | `requestId` | `string` | correlates with `action:complete` | | `path` | `string` | URL pathname + search | | `actionName` | `string` | action name (`'default'` if unnamed) | ```ts mochiEvents.on('action:invoke', ({ requestId, actionName }) => { starts.set(requestId, performance.now()); }); ``` #### `action:complete` Fires after a form action returns (or throws). One emission per invocation, regardless of outcome. | Field | Type | Notes | | ------------ | ---------------------------------------------- | ------------------------------- | | `requestId` | `string` | correlates with `action:invoke` | | `path` | `string` | URL pathname + search | | `actionName` | `string` | action name | | `result` | `'success' \| 'fail' \| 'redirect' \| 'error'` | outcome category | | `status` | `number \| undefined` | set for `fail` and `redirect` | ```ts mochiEvents.on('action:complete', ({ requestId, actionName, result }) => { const ms = performance.now() - (starts.get(requestId) ?? 0); starts.delete(requestId); metrics.timing('form.action', ms, { actionName, result }); }); ``` #### `compile:start` Fires before each Svelte SSR compile (skipped on cache hit). | Field | Type | Notes | | ------ | -------- | --------------------------- | | `path` | `string` | absolute path of the source | #### `compile:complete` Fires after a successful compile. Pair with `compile:start` to time the build per file. | Field | Type | Notes | | ------------------- | -------- | --------------------------- | | `path` | `string` | absolute path of the source | | `ssrSizeBytes` | `number` | size of the SSR bundle | | `hydratableCount` | `number` | hydratable islands found | | `serverIslandCount` | `number` | server islands found | ```ts const compileStart = new Map(); mochiEvents.on('compile:start', ({ path }) => compileStart.set(path, performance.now())); mochiEvents.on('compile:complete', ({ path, ssrSizeBytes }) => { const ms = performance.now() - (compileStart.get(path) ?? 0); log.info(`compiled ${path} in ${ms}ms (${ssrSizeBytes} bytes)`); compileStart.delete(path); }); ``` #### `compile:error` Fires when `Bun.build` rejects a Svelte source. The framework still throws after emitting; the event is for tooling that wants the structured logs. | Field | Type | Notes | | --------- | ------------------------------------------- | -------------------------------- | | `path` | `string` | source that failed | | `message` | `string` | top-line error message | | `logs` | `Array<{ file?, line?, column?, message }>` | per-message diagnostics from Bun | #### `island:error` Fires when an island fails — server-island render, hydratable SSR render, or client-side hydration. The framework still ships an error placeholder; this event lets you observe it. | Field | Type | Notes | | --------------- | ---------------------------------------------- | ---------------------------------- | | `componentName` | `string` | island component identifier | | `islandId` | `string \| undefined` | DOM `island-id` if known | | `kind` | `'hydratable' \| 'server' \| 'client-hydrate'` | which lifecycle stage failed | | `message` | `string` | error message | | `stack` | `string \| undefined` | stack trace, populated in dev only | ```ts mochiEvents.on('island:error', ({ componentName, kind, message }) => { log.error(`[${kind}] ${componentName}: ${message}`); }); ``` #### `file:change` Fires from the dev file watcher (chokidar). Production builds don't run the watcher, so this event never emits there. | Field | Type | Notes | | ------ | --------------------- | ---------------------------------------------------------- | | `path` | `string` | absolute path of the changed file | | `type` | `MochiFileChangeType` | `'add' \| 'change' \| 'unlink' \| 'addDir' \| 'unlinkDir'` | ```ts mochiEvents.on('file:change', ({ path, type }) => { if (type === 'change' && path.endsWith('.env')) restartWorkers(); }); ``` ### Custom events `mochiEvents` is a plain mitt emitter — you can `emit` your own keys on it. They won't appear in `MochiEventMap`, so handlers and emit sites lose their typing. For anything beyond a quick experiment, prefer a separate emitter you control. --- title: 'Custom HTML shell' slug: custom-html-shell --- ## Custom HTML shell Pass an HTML file path or template string to `htmlShell`. Use these placeholders: | Placeholder | Replaced with | | ------------------ | ---------------------------------- | | `{{mochi.head}}` | `` content | | `{{mochi.css}}` | Scoped component styles | | `{{mochi.body}}` | Rendered HTML | | `{{mochi.script}}` | Hydration bootstrap ` ``` The import is stripped from both the SSR and client JS bundles (so no runtime cost) and the CSS is bundled separately. Per-entry tracking ensures pages only ship the CSS they actually depend on. The same import in a hydratable component works the same way — the bundle URL is linked from the page `` regardless of where the import lives. Use this for self-hosted fonts (`@fontsource/*`), CSS-only libraries (`tippy.js/dist/tippy.css`), or any third-party stylesheet you'd rather not load via a runtime ``. In dev, a `.css` edit triggers a fast rebundle (no full SSR recompile) and a page reload. Edits to `.svelte` / `.ts` files trigger the normal full recompile path. --- title: 'Serve options' slug: serve-options --- ## Serve options Selected `Mochi.serve()` options (see `MochiServeOptions` for the full list): | Option | Default | Description | | --------------------------- | ------------------- | --------------------------------------------------------------------------- | | `port` | — | Port to listen on | | `development` | `true` | Enables live reload, debug bar, and error overlay | | `htmlShell` | built-in | Path or template string for the HTML shell | | `publicDir` | `./public` | Directory served as static assets | | `outDir` | `./.mochi` | Directory for build artifacts and dev cache | | `assetPrefix` | `/_mochi` | URL prefix for framework client assets and the server-island endpoint | | `additionalWatchPaths` | `[]` | Extra dev-mode file watcher paths, added to the defaults `src` and `public` | | `gzip` | `true` | Gzip-compress responses when the client sends `Accept-Encoding: gzip` | | `compressServerIslandProps` | `true` | Deflate-compress server island props when it reduces size | | `logger` | `{ enabled: true }` | Built-in request logger; pass `{ enabled: false }` to disable | ```ts await Mochi.serve({ port: 3333, gzip: false, // disable response compression (e.g. when a reverse proxy handles it) routes, }); ``` **One `Mochi.serve()` per process.** Calling it a second time throws `Mochi.serve() has already been called. Only one instance is allowed.` To run two sites side by side (e.g. `site` + `demos` in this repo), start them as separate processes on different ports. **Shutdown signals.** `Mochi.serve()` installs `SIGTERM` and `SIGINT` listeners that fire the [`mochi:shutdown`](/docs/extensions#mochishutdown) hook and call `server.stop()`. A second signal force-exits with code 1. Existing user listeners on those signals are not displaced — Node.js dispatches signals to every registered listener. ### CSRF State-mutating form submissions (`POST` / `PUT` / `PATCH` / `DELETE` with `application/x-www-form-urlencoded`, `multipart/form-data`, or `text/plain`) are gated by an origin-header check: the request's `Origin` must match the expected origin (see [Proxy](#proxy) below) or appear in `csrf.trustedOrigins`. JSON endpoints rely on the browser's CORS preflight and aren't checked. **Safe by default.** In production the check refuses every form mutation until you set [`proxy.origin`](#proxy) (or `proxy.hostHeader`) so the framework knows what origin to trust. The 403 body explains the missing config so the deployment break is loud rather than silent. In development the same request is allowed through with a `[mochi]` warning instead, so local work isn't blocked. Opt out entirely with `csrf.checkOrigin: false` if you really mean to. A configured production setup looks like this: ```ts await Mochi.serve({ proxy: { origin: 'https://app.example.com', }, csrf: { trustedOrigins: ['https://embed.partner.com'], // optional extra origins // checkOrigin: false, // disable the check entirely }, routes, }); ``` ### Proxy Behind a reverse proxy (load balancer, CDN, ngrok), the connection Bun sees is from the proxy, not the client. `proxy` options tell 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. > **Only set the header options when the proxy is trusted to overwrite them** — clients can spoof these headers when reaching the app directly. | Option | Use when | Example | | ---------------- | -------------------------------------------------------------------------------------------- | --------------------- | | `origin` | The public URL is fixed and known. Wins over the header options. | `'https://my.site'` | | `protocolHeader` | The proxy sets a forwarded-protocol header you trust. | `'x-forwarded-proto'` | | `hostHeader` | The proxy sets a forwarded-host header you trust. | `'x-forwarded-host'` | | `portHeader` | The proxy listens on a non-standard port and forwards it. | `'x-forwarded-port'` | | `addressHeader` | The proxy forwards the client IP (e.g. `True-Client-IP`, `X-Forwarded-For`). | `'x-forwarded-for'` | | `xffDepth` | Number of trusted proxies in front of the server, when `addressHeader` is `x-forwarded-for`. | `3` | ```ts 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', // optional, only if the public port differs // 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 a comma-separated chain — each proxy appends the address it saw. With three trusted proxies in front of the server and no spoofing: ``` client, proxy1, proxy2 ``` The 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) ``` If you need the leftmost address instead — for example, geolocation where the IP being real matters more than its being trusted — read `request.headers.get('x-forwarded-for')` directly in your handler. #### `getClientAddress()` on the request context ```ts import { getRequestContext } 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). --- title: 'Extensions (hooks & filters)' slug: extensions --- ## 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`](#mochiinit) — async - [`mochi:ready`](#mochiready) — async - [`mochi:shutdown`](#mochishutdown) — async - [`route:matched`](#routematched) — sync Filters: - [`csrf:formContentTypes`](#csrfformcontenttypes) — sync - [`csrf:protectedMethods`](#csrfprotectedmethods) — sync - [`csrf:trustedOrigins`](#csrftrustedorigins) — sync - [`csrf:check`](#csrfcheck) — sync - [`cookie:defaults`](#cookiedefaults) — sync - [`html:shell`](#htmlshell) — sync - [`serverIsland:secretKey`](#serverislandsecretkey) — async - [`compile:preprocessors`](#compilepreprocessors) — sync - [`publicDir:scan`](#publicdirscan) — async ### Hooks #### `mochi:init` Fires as the very first thing inside `Mochi.serve()`, before any framework state is set up. Async. ```ts 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. ```ts 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. ```ts 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. ```ts 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` of content types that gate the built-in CSRF check. Resolved once at startup. Sync. ```ts 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` of HTTP methods that the CSRF check applies to. Resolved once at startup. Sync. ```ts 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` of cross-origin sources allowed past the CSRF check. Seeded from `csrf.trustedOrigins` (an array on `Mochi.serve()`). Resolved once at startup. Sync. ```ts 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. ```ts 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`](#csrftrustedorigins) 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. ```ts 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}}`, `{{mochi.script}}` placeholders intact. Sync. ```ts await Mochi.serve({ filters: { 'html:shell': (tpl) => tpl.replace('{{mochi.head}}', '{{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. ```ts 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). ```ts 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` 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. ```ts 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. --- title: 'Svelte config' slug: svelte-config --- ## Svelte config Drop a `svelte.config.js` at the root of your app to customize the Svelte compiler. Mochi reads it on startup (and during `mochi build`) and merges its `compilerOptions` into the framework defaults. ```js // svelte.config.js export default { compilerOptions: { experimental: { async: true, }, }, }; ``` The file is optional. If it is missing, Mochi logs a warning and uses its defaults — the most important one being `experimental.async: true`, which is what allows `await` at the top level of `.svelte` components and is currently a Svelte 5 opt-in. ### Custom config path By default Mochi looks for `./svelte.config.js` at the current working directory. Point it elsewhere with `svelteConfigPath` on `Mochi.serve()` or `build()` — relative paths resolve against `process.cwd()`, absolute paths are used as-is: ```ts await Mochi.serve({ svelteConfigPath: './configs/svelte.staging.config.js', routes, }); ``` ### Merge order 1. **Framework defaults** 2. **Your `compilerOptions`** — overlaid on top of the defaults. You can override most fields. ### Framework-owned fields A small set of `compilerOptions` are managed by Mochi and cannot be overridden, because they are part of the framework's contract with the compiler: | Field | Why | | ---------- | ---------------------------------------------------------- | | `generate` | Set to `'server'` for SSR builds, `'client'` for hydration | | `filename` | Always the actual file path | | `dev` | Tied to the `development` flag passed to `Mochi.serve()` | Everything else is yours to set. ### Where it applies The merged options are used everywhere Mochi invokes the Svelte compiler: - SSR compilation of `.svelte` files - SSR compilation of `.svelte.js` / `.svelte.ts` rune modules - Client-side island bundles (both `.svelte` and `.svelte.[jt]s`) - mdsvex `.md` / `.svx` files (server target) ### Limitations Only `compilerOptions` is read today. SvelteKit-style keys like `preprocess`, `extensions`, and `kit` are not supported. --- title: 'Development mode' slug: development-mode --- ## Development mode Development mode is controlled by the `development` option on `Mochi.serve()`. It defaults to `true`, so if you do nothing you get the dev experience. ```ts await Mochi.serve({ development: true, // the default routes, }); ``` When enabled, Mochi turns on: - **Live reload** — pages refresh automatically on file changes (SSE-based; browser listens on `/__mochi_live_reload`) - **File watcher** — `chokidar` watches source files; changes invalidate the component compile cache and notify any subscribers - **Debug bar** — injected into HTML with component and hydration info - **Error overlay** — build and runtime errors displayed in-browser - **Bundle stats** — available at `/_mochi/client/stats` (or `${assetPrefix}/client/stats` if customized) ### The `MODE=development` convention You'll often see the dev entry wired from a `MODE` env var: ```ts // src/index.ts await Mochi.serve({ development: process.env.MODE === 'development', routes, }); ``` ```json // package.json { "scripts": { "dev": "MODE=development bun src/index.ts", "start": "bun src/index.ts" } } ``` `MODE` is just a user-space convention — the framework does not read it directly. The framework only reads `options.development`. Using an env var lets one entry file serve both `bun run dev` (dev mode) and `bun run start` (production). ### What the file watcher covers By default, chokidar watches: - `src/` — your application source - `public/` — static assets Use `additionalWatchPaths` to extend this (for example, a separate content directory): ```ts await Mochi.serve({ additionalWatchPaths: ['../content', './docs'], routes, }); ``` The defaults are always included — `additionalWatchPaths` is purely additive. Paths that don't exist on disk are silently skipped. ### Reacting to file changes (`file:change` event) Every filesystem change detected by the watcher is emitted on the `mochiEvents` bus as a `file:change` event. Use this to invalidate your own caches on edit without restarting the server: ```ts import { mochiEvents } from 'mochi-framework'; if (process.env.MODE === 'development') { mochiEvents.on('file:change', ({ path, type }) => { if (path.endsWith('.md')) { // path is absolute; type is 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir' clearMyMarkdownCache(); } }); } ``` The event fires synchronously, before the debounced browser reload, so subscribers can refresh their state in time for the next request. `mochiEvents` is also available from `.svelte` files during SSR. All server-side copies of the emitter (the main runtime and every SSR bundle) share one pinned instance, so a subscription made from a component running server-side sees events emitted by the framework. On the client, `mochiEvents` is a stub — `on`/`off`/`setHandler` are no-ops and `emit` logs a warning to the console, since the bus is server-only. #### Subscribing from a module that isn't your server entry If your subscribing module can end up bundled into an SSR island (either directly via `import` in a `.svelte` file, or transitively), prefer `mochiEvents.setHandler(name, type, handler)` over `.on(...)`. The dev compile cache re-imports each SSR bundle on every `.svelte` change, so a module-top-level `.on(...)` registers a fresh handler each time and they pile up. `setHandler` replaces any prior registration with the same name — so 15 re-imports collapse to 1 live subscriber. ```ts import { mochiEvents } from 'mochi-framework'; mochiEvents.setHandler('docs-cache-clear', 'file:change', ({ path }) => { if (path.endsWith('.md')) clearMyMarkdownCache(); }); ``` Plain `.on(...)` remains the right choice when the subscribing module runs exactly once (e.g., your server entry) or when you legitimately want multiple handlers fanning out. In production (`development: false`) the watcher is never started and no `file:change` events are emitted. --- title: 'Tailwind' slug: tailwind --- ## Tailwind Drive Tailwind v4 with its Node API — no PostCSS, no Vite. Mochi ships an opt-in helper at `mochi-framework/tailwind` that compiles your input CSS at server startup and re-runs on file changes in dev. Then `import` the generated file from any `.svelte` and Mochi's [CSS-import bundler](/docs/css-imports) links it scoped to the page. Install Tailwind alongside its standalone Node + scanner packages (`mochi-framework` is already in your project; the Tailwind packages are optional peers): ```sh bun add tailwindcss @tailwindcss/node @tailwindcss/oxide ``` Write an input CSS that imports the layers you want and tells Tailwind where to scan: ```css /* src/styles/app.css */ @import 'tailwindcss/theme.css' layer(theme); @import 'tailwindcss/utilities.css' layer(utilities); @source './*.svelte'; /* Preflight is skipped above. If your host shell already ships an unlayered universal reset (`*, *::before, *::after { margin: 0; padding: 0 }`), add element-level resets here for tags whose UA defaults you care about. */ button { appearance: none; background: transparent; border: 0; font: inherit; color: inherit; cursor: pointer; } ``` `@source` paths are resolved against the base directory passed to compile(). The helper sets base to the input file's directory by default, so ./*.svelte matches files next to app.css. Use `**/*.svelte` for nested folders, or pass an explicit base. Call `setupTailwind` before `Mochi.serve()`: ```ts // src/index.ts import { setupTailwind } from 'mochi-framework/tailwind'; await setupTailwind({ input: './src/styles/app.css', output: './src/styles/app.generated.css', minify: process.env.MODE !== 'development', }); ``` Then `import` the generated file from any `.svelte` that uses Tailwind classes: ```svelte ``` The framework strips the import from the JS bundle and serves the CSS at `/_mochi/import-css/.css`. The `` is added to the `` of every page that transitively imports it — pages that don't use Tailwind don't pay for it. In dev, `setupTailwind` subscribes to Mochi's file watcher and rebuilds on `.svelte` / `.ts` / `.css` changes; the resulting `.css` write goes through Mochi's CSS fast-path (no full SSR rebuild) and the page reloads. The example skips Tailwind's preflight so the stylesheet doesn't reset unrelated UI when loaded onto pages that already have their own resets. The cost is that user-agent defaults for elements like <button> leak through (rounded macOS pill shape, default gray background) and clobber utilities like rounded-lg or bg-transparent. Add element-level resets in your input CSS for any tags you actually use, or `@import 'tailwindcss/preflight.css' layer(base);` to opt back in. Add `app.generated.css` to `.gitignore` — it's a build artifact. --- title: 'Architecture' slug: architecture --- ## Architecture | File | Role | | ------------------------------------------- | --------------------------------------------------------- | | `src/index.ts` | HTTP server entry point | | `src/routes.ts` | Route definitions | | `src/mochi-framework/Mochi.ts` | `Mochi.serve()`, `page()`, `api()`, `ws()`, `sse()` | | `src/mochi-framework/ComponentRegistry.ts` | SSR compilation, hydration preprocessing, client bundling | | `src/mochi-framework/hooks.ts` | Middleware system (`Handle`, `sequence()`) | | `src/mochi-framework/utils.ts` | `json()`, `error()`, gzip | | `src/mochi-framework/HydratableIsland.ts` | Client-side custom element for island hydration | | `src/mochi-framework/ServerIsland.ts` | Client-side custom element for server island fetching | | `src/mochi-framework/serverIslandCrypto.ts` | HMAC signing/verification for server island props | | `src/mochi-framework/types.ts` | Shared TypeScript types | --- title: 'Docs for LLMs' slug: docs-for-llms --- ## Docs for LLMs The Mochi documentation is published as plain-text bundles you can paste into an LLM context. ### Full documentation The full set of docs, concatenated in reading order, is served at [`/llms.txt`](/llms.txt) — **recommended**. Use this when you want the model to have the complete picture of the framework API. ### Docs + demo source [`/llms-full.txt`](/llms-full.txt) includes everything in `/llms.txt` plus the full source of every demo (`.svelte` and `.ts` files), grouped by demo name. Use this when you want the model to understand both the API and real working examples. ### Per-document text Each individual doc is also reachable as plain text at `/docs//llms.txt`. For example: - [`/docs/intro/llms.txt`](/docs/intro/llms.txt) - [`/docs/server-islands/llms.txt`](/docs/server-islands/llms.txt) - [`/docs/api-routes/llms.txt`](/docs/api-routes/llms.txt) The "Copy as llms.txt" button on each doc page emits just that page — useful when you want to give the model focused context without the rest of the framework. # Demo Source Files ## Demo: api ### api/Api.svelte ```svelte ``` ### api/ApiTester.svelte ```svelte
+
{#if loading} Loading... {:else if result}
{result}
{:else} Click an endpoint to test it {/if}
``` ### api/routes.ts ```ts import { Mochi, error } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/api': Mochi.page('./src/demos/api/Api.svelte'), '/health': Mochi.api(({ method }) => Response.json({ status: 'ok', method })), // curl -X POST http://localhost:3333/add -H 'Content-Type: application/json' -d '{"a": 2, "b": 3}' '/add': Mochi.api(async ({ method, request }) => { if (method !== 'POST') { error(405, 'Method Not Allowed'); } const { a, b } = (await request.json()) as { a: number; b: number }; return Response.json({ result: a + b }); }), }; ``` ## Demo: cache-events ### cache-events/CacheEvents.svelte ```svelte
Cached value {time}
Status {status}

Refresh under 3s for a fresh hit. Refresh between 3–10s for stale + a background revalidate. After 10s the next refresh blocks on a fresh fetch (expired). Each event prints a line in the server console.

``` ### cache-events/log.ts ```ts import { MochiCache, mochiEvents } from 'mochi-framework'; import { delay } from '../../components/utils'; export const slowClock = new MochiCache({ minTimeToStale: 3_000, maxTimeToLive: 10_000, }); // Custom integration: every cache event is printed to the console with a // distinctive prefix so it stands out from the framework's built-in logger // lines. setHandler (rather than .on) means dev re-imports don't pile up // duplicate subscribers. mochiEvents.setHandler('demo:cache-events:read', 'cache:read', ({ key, status }) => { console.log(`[demo:cache-events] read ${key} → ${status}`); }); mochiEvents.setHandler('demo:cache-events:revalidate', 'cache:revalidate', ({ key }) => { console.log(`[demo:cache-events] revalidate ${key}`); }); export async function getSlowTime() { return slowClock.fetchWithStatus('slow-clock', async () => { await delay(150); return new Date().toISOString(); }); } ``` ### cache-events/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/cache-events': Mochi.page('./src/demos/cache-events/CacheEvents.svelte'), }; ``` ## Demo: chat ### chat/Chat.svelte ```svelte ``` ### chat/ChatWidget.svelte ```svelte
Chat
{#each messages as msg, i (i)}
{msg.text}
{/each} {#if messages.length === 0}
Send a message to get started
{/if}
``` ### chat/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/chat': Mochi.page('./src/demos/chat/Chat.svelte'), '/ws/chat': (() => { const history: string[] = []; const TOPIC = 'chat'; return Mochi.ws({ open(ws) { ws.subscribe(TOPIC); for (const msg of history) { ws.send(msg); } }, message(ws, message) { const text = String(message); history.push(text); // send to all subscribers including the sender ws.publish(TOPIC, text); ws.send(text); }, close(ws) { ws.unsubscribe(TOPIC); }, }); })(), }; ``` ## Demo: cookies ### cookies/CookieDemo.svelte ```svelte ``` ### cookies/Cookies.svelte ```svelte ``` ### cookies/routes.ts ```ts import { Mochi, error, getRequestContext } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/cookies': Mochi.page('./src/demos/cookies/Cookies.svelte'), '/api/cookie': Mochi.api(async ({ method, request }) => { if (method !== 'POST') { error(405, 'Method Not Allowed'); } const { username, theme } = (await request.json()) as { username: string; theme: string; }; const { cookies } = getRequestContext(); cookies.set('mochi_username', username, { path: '/', maxAge: 604800 }); cookies.set('mochi_theme', theme, { path: '/', maxAge: 604800 }); return Response.json({ ok: true }); }), }; ``` ## Demo: data-loading ### data-loading/DataLoading.svelte ```svelte {#if pokemon}
{:else}

No Pokémon found for "{id}".

{/if}
``` ### data-loading/PokemonHeader.svelte ```svelte

{isServer ? 'SERVER' : 'BROWSER'} | {isDev ? 'DEV' : 'PROD'}

{name}
#{String(id).padStart(3, '0')}

{name}

``` ### data-loading/PokemonMeta.svelte ```svelte
{#each types as t (t)} {t} {/each}
Height {(height / 10).toFixed(1)} m
Weight {(weight / 10).toFixed(1)} kg
Base XP {baseXp}
Abilities {abilities.join(', ')}
``` ### data-loading/PokemonSelector.svelte ```svelte ``` ### data-loading/PokemonStats.svelte ```svelte

Base Stats

{#each filtered as s (s.name)}
{s.name} {s.value}
{/each}
``` ### data-loading/routes.ts ```ts import { Mochi, fail, redirect } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/data-loading': (req: Request) => Response.redirect(new URL('/demos/data-loading/pikachu', req.url), 302), '/demos/data-loading/:id': Mochi.page('./src/demos/data-loading/DataLoading.svelte', { actions: { default: async ({ formData }) => { const pokemon = String(formData.get('pokemon') ?? '') .trim() .toLowerCase(); if (!pokemon) { return fail(400, { error: 'Pokemon required' }); } return redirect(303, `/demos/data-loading/${encodeURIComponent(pokemon)}`); }, }, }), }; ``` ## Demo: devalue ### devalue/Devalue.svelte ```svelte ``` ### devalue/DevalueDemo.svelte ```svelte
{#each rows as row (row.label)} {@const clientType = isBrowser ? typeOf(row.value) : ''} {@const serverType = serverTypes?.[row.label] ?? '—'} {/each}
Type Value Server Type Client Type
{row.label} {display(row.value)} {serverType} {clientType}
Repeated ref {repeatedRef?.[0] === repeatedRef?.[1] ? 'same ref' : 'different refs'} identity check {isBrowser ? 'identity check' : ''}
Cyclic ref {cyclicRef?.self === cyclicRef ? 'self === obj' : 'broken'} identity check {isBrowser ? 'identity check' : ''}

{isBrowser ? 'Client (hydrated)' : 'Server (SSR)'}

``` ### devalue/devalueTypeOf.ts ```ts export function typeOf(v: unknown): string { if (v === undefined) { return 'undefined'; } if (v === null) { return 'null'; } if (typeof v === 'bigint') { return 'BigInt'; } if (typeof v === 'number') { if (Number.isNaN(v)) { return 'NaN'; } if (v === Infinity) { return 'Infinity'; } if (Object.is(v, -0)) { return '-0'; } } if (v instanceof Date) { return 'Date'; } if (v instanceof RegExp) { return 'RegExp'; } if (v instanceof Map) { return 'Map'; } if (v instanceof Set) { return 'Set'; } if (v instanceof URL) { return 'URL'; } if (v instanceof URLSearchParams) { return 'URLSearchParams'; } if (v instanceof Uint8Array) { return 'Uint8Array'; } if (ArrayBuffer.isView(v)) { return (v as { constructor: { name: string } }).constructor.name; } if (Array.isArray(v)) { return 'Array'; } return typeof v; } ``` ### devalue/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/devalue': Mochi.page('./src/demos/devalue/Devalue.svelte'), }; ``` ## Demo: error-boundaries ### error-boundaries/ErrorBoundaries.svelte ```svelte {#snippet caughtFallback(error: unknown)}
Caught by user-written boundary: {error instanceof Error ? error.message : String(error)}
{/snippet}

1. Non-hydrated component + user <svelte:boundary>

Non-hydrated components don't get an automatic boundary. If they can throw during SSR, wrap them in a hand-written <svelte:boundary> yourself — otherwise the throw bubbles up and crashes the whole request.

2. mochi:hydrate — SSR throw (recovery)

The framework auto-wraps every mochi:hydrate island in a boundary. This island throws on the server only — the boundary catches the SSR throw and the rest of the page renders unaffected.

3. mochi:hydrate — client throw

SSR is fine, but the script throws synchronously once the island tries to hydrate. The defensive try/catch around hydrate() catches it and swaps the island for the failure stub — the rest of the page (already rendered on the server) is untouched.

4. mochi:defer — server island throw

Server islands render at a separate endpoint. When that render throws, the endpoint catches it and returns a 200 with an error stub — so the browser doesn't burn its retry budget on a deterministic failure. The user-supplied loading children stay until the response arrives.

Loading from server…

5. mochi:defer — healthy island, inner mochi:hydrate client throw

The server island itself renders successfully — it's the inner mochi:hydrate child that throws once it tries to hydrate on the client. The child's auto-boundary catches its own failure, so the rest of the server island content stays intact.

Loading from server…
``` ### error-boundaries/HealthyServerIsland.svelte ```svelte

The server island itself rendered successfully.

Inside it sits a mochi:hydrate child that throws on the client — its boundary catches the failure independently:

``` ### error-boundaries/ThrowOnClient.svelte ```svelte
This island renders fine on the server. Once the client tries to hydrate, the script throws synchronously — the boundary catches it and swaps in the failure stub.
``` ### error-boundaries/ThrowOnServerIsland.svelte ```svelte
never rendered
``` ### error-boundaries/ThrowOnSsr.svelte ```svelte
On the server this island threw, so the boundary swapped in the failure stub — you briefly see it flash before hydration takes over, or if you disable JavaScript. Then the client re-renders the component cleanly and replaces the stub with this content. This works but is not recommended.
``` ### error-boundaries/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/error-boundaries': Mochi.page('./src/demos/error-boundaries/ErrorBoundaries.svelte'), }; ``` ## Demo: error ### error/Error500.svelte ```svelte

You should never see this — the page throws during SSR.

``` ### error/ErrorDemo.svelte ```svelte

Mochi catches any throw from a page's serverProps resolver or Svelte <script>, along with any request that doesn't match a route, and renders the built-in default error page. Pass your own errorPage to Mochi.serve() to replace it. The handleError hook runs for every such error — use it to log, forward to an error tracker, sanitize the message, or return a Response to short-circuit rendering entirely.

Try it:

  • /demos/error/500 the page's <script> throws during SSR
  • /demos/error/404 the serverProps resolver calls error(404, ...)
  • /does-not-exist no route matches — handleError fires with error: null, then the error page renders with status 404
  • /demos/error/redirect the page throws, but handleError returns Response.redirect(...) — you land back on this page instead of seeing the error component

A custom error component receives a single error prop with status, message, and (in development only) stack — typed as MochiErrorProps.

This site's handleError:

{@html handleErrorHtml}

Tail your dev server's output while clicking the links above — you'll see one [handleError] line per visit. Unmatched routes log error null; SSR throws log error present.

``` ### error/routes.ts ```ts import { Mochi, error } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/error': Mochi.page('./src/demos/error/ErrorDemo.svelte'), '/demos/error/500': Mochi.page('./src/demos/error/Error500.svelte'), '/demos/error/404': Mochi.page('./src/demos/error/Error500.svelte', { serverProps: () => { error(404, 'This item does not exist.'); }, }), // Throws during SSR, but the site-wide handleError returns a redirect Response // for this pathname, so the error page is never rendered. '/demos/error/redirect': Mochi.page('./src/demos/error/Error500.svelte'), }; ``` ## Demo: file-upload ### file-upload/FileUpload.svelte ```svelte

{label}

{#if errorMessage} {/if} {#if fileResult}

{fileResult.filename} — {fileResult.size} bytes

{fileResult.content}
{/if}
{#if isHydratable} {selectedName ?? 'No file chosen'} {/if}
``` ### file-upload/FileUploadDemo.svelte ```svelte

The action reads a .txt or .md file from multipart/form-data and echoes its name and content back via success(). With {'{@attach enhance(...)}'} the content appears inline; without it the page re-renders with the file content from the form snapshot.

With {'{@attach enhance(...)}'}

Plain HTML

``` ### file-upload/routes.ts ```ts import { Mochi, fail, success } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/file-upload': Mochi.page('./src/demos/file-upload/FileUploadDemo.svelte', { actions: { uploadFile: async ({ formData }) => { const file = formData.get('file'); if (!(file instanceof File) || file.size === 0) { return fail(400, { error: 'No file selected' }); } const ext = file.name.split('.').pop()?.toLowerCase() ?? ''; if (ext !== 'txt' && ext !== 'md') { return fail(400, { error: 'Only .txt and .md files are accepted' }); } if (file.size > 100 * 1024) { return fail(400, { error: 'File too large (max 100 KB)' }); } const content = await file.text(); return success({ filename: file.name, content, size: file.size }); }, }, }), }; ``` ## Demo: font-loading ### font-loading/FontLoading.svelte ```svelte

Fontsource package

Add @fontsource/jetbrains-mono as a dependency and side-effect-import it from any component. The framework bundles the package's CSS (with woff2 inlined as data URIs), serves it from /_mochi/import-css/*.css, and links it from every page that transitively imports the component.

The quick brown fox jumps over the lazy dog. 1234567890

Standalone .woff2

Drop a .woff2 next to your component and reference it from a tiny @font-face CSS file:

{`@font-face {
  font-family: 'Lobster';
  src: url('./lobster.woff2') format('woff2');
}`}

Side-effect-import the CSS from the component (import './lobster.css'). Bun's CSS bundler inlines the .woff2 as a base64 data URI in the bundled CSS, so the font ships in the same request as the stylesheet — no separate font fetch.

The quick brown fox jumps over the lazy dog. 1234567890

Inside a hydratable island

The same import works in a component with mochi:hydrate. The framework strips the CSS import from both the SSR and the client bundle, then links the bundled stylesheet from the page <head>. The badge flips to hydrated in the browser; the styled text uses the bundled font on first paint with no JS-injected styles.

``` ### font-loading/HydratedBox.svelte ```svelte
Hydrated island {isBrowser ? 'hydrated' : 'server-rendered only'}

Lobster Two ships with this island.

``` ### font-loading/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/font-loading': Mochi.page('./src/demos/font-loading/FontLoading.svelte'), }; ``` ## Demo: form-cancel ### form-cancel/FormCancel.svelte ```svelte

The action sleeps 3 seconds to simulate a slow lookup. With {'{@attach enhance(...)}'}, submitting the same query twice calls cancel() — the fetch never fires. The visible "Cancel" button calls controller.abort() to stop the in-flight request. Without enhancement, it is a plain POST that waits for the server.

With {'{@attach enhance(...)}'}

Plain HTML

``` ### form-cancel/LookupDemo.svelte ```svelte

{label}

{#if pending} {/if}
{#if result}

{result}

{/if} {#if message} {/if}
``` ### form-cancel/routes.ts ```ts import { Mochi, fail, success } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/form-cancel': Mochi.page('./src/demos/form-cancel/FormCancel.svelte', { actions: { lookup: async ({ formData }) => { const query = String(formData.get('query') ?? '').trim(); if (!query) { return fail(400, { error: 'Query is required' }); } await Bun.sleep(3000); return success({ result: `Found: ${query} — status active` }); }, }, }), }; ``` ## Demo: form-errors ### form-errors/ErrorDemo.svelte ```svelte

{label}

{#if errorMessage} {/if}
``` ### form-errors/FormErrors.svelte ```svelte

The action throws a plain Error. With {'{@attach enhance(...)}'} the error message is shown inline. Without it, the server renders the Mochi error page.

With {'{@attach enhance(...)}'}

Plain HTML

``` ### form-errors/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/form-errors': Mochi.page('./src/demos/form-errors/FormErrors.svelte', { actions: { throwError: () => { throw new Error('Something went wrong on the server'); }, }, }), }; ``` ## Demo: form-redirects ### form-redirects/FormRedirects.svelte ```svelte {#if redirected}

Redirected here via HTTP 303 (non-enhanced path).

{/if}

The action returns redirect(303, …). With {'{@attach enhance(...)}'} the JSON envelope is intercepted so you can inspect it before navigating. Without it, the browser follows the HTTP 303.

With {'{@attach enhance(...)}'}

Plain HTML

``` ### form-redirects/RedirectDemo.svelte ```svelte

{label}

{#if redirectResult}

Server returned type: "redirect"

status: {redirectResult.status}

location: "{redirectResult.location}"

{:else} {/if}
``` ### form-redirects/routes.ts ```ts import { Mochi, redirect, getRequestContext } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/form-redirects': Mochi.page('./src/demos/form-redirects/FormRedirects.svelte', { serverProps: () => { const { url } = getRequestContext(); return { redirected: url.searchParams.has('redirected') }; }, actions: { doRedirect: () => redirect(303, '/demos/form-redirects?redirected=1'), }, }), }; ``` ## Demo: form-return-data ### form-return-data/FormReturnData.svelte ```svelte

A minimal action that returns a random number via success({'{ value }'}). The hydrated version updates the input reactively; the non-hydrated version re-renders the whole page and the component reads the value from getRequestContext().form.

With {'{@attach enhance(...)}'}

Plain HTML

``` ### form-return-data/RandomRoll.svelte ```svelte

{label}

``` ### form-return-data/routes.ts ```ts import { Mochi, success } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/form-return-data': Mochi.page('./src/demos/form-return-data/FormReturnData.svelte', { actions: { random: () => success({ value: Math.floor(Math.random() * 100) + 1 }), }, }), }; ``` ## Demo: hello-world ### hello-world/HelloWorld.svelte ```svelte

Hello, world!

``` ### hello-world/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/hello-world': Mochi.page('./src/demos/hello-world/HelloWorld.svelte'), }; ``` ## Demo: hydration ### hydration/Hydration.svelte ```svelte

No directive

Pure SSR — no client JavaScript is shipped for this component. It renders on the server and is inert in the browser. Best for content that never needs interactivity.

mochi:hydrate

Eager hydration — the island boots as soon as its bundle arrives, making it interactive immediately. Use this for above-the-fold UI that the user will touch right away.

mochi:hydrate:visible

Lazy hydration — the bundle and CSS are only fetched once the island scrolls into view (via an IntersectionObserver). The card below stays inert until you scroll it into the viewport.

↓ Scroll down ↓

mochi:hydrate:visible={'{{ rootMargin }}'}

You can pass IntersectionObserver options — here rootMargin: '200px' starts hydration a bit before the island is actually in view.

`} />

mochi:defer

Server island — the component is not rendered with the initial page. Instead, a placeholder ships and the browser fetches the rendered HTML from the server on-demand. Pair with mochi:hydrate to also make it interactive after it loads.

Loading from server…
``` ### hydration/HydrationTarget.svelte ```svelte
{label} {isBrowser ? 'hydrated' : 'server-rendered only'}
rendered at {renderedAt}
{#if !isBrowser}

This button won't work since the component is not hydrated!

{/if} {@render children?.()}
``` ### hydration/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/hydration': Mochi.page('./src/demos/hydration/Hydration.svelte'), }; ``` ## Demo: island-props ### island-props/ClientRenderedChild.svelte ```svelte
ClientRenderedChild.svelte {isBrowser ? 'Client (hydrated)' : 'Server (SSR)'}
{#each rows as row (row.label)} {/each}
Prop Value Runtime type
{row.label} {row.display()} {describe(row.value)}
``` ### island-props/ServerRenderedParent.svelte ```svelte

ServerRenderedParent.svelte

Runs only on the server. No mochi:hydrate directive, so this component's own code ships zero JavaScript — its output is inert HTML. It builds a props bag with mixed types and hands it off to the child below.

{`const user = { name: 'Ada', id: 42 };
const visitedAt = new Date();
const tags = new Set(['svelte', 'bun', 'islands']);
const scores = new Map([['speed', 95], ['dx', 88], ['size', 72]]);`}

How the props get there

  1. At SSR time, Mochi calls devalue.stringify on the props you pass to the island. Unlike JSON.stringify, it preserves Date, Map, Set, BigInt, RegExp, URL, typed arrays, undefined, NaN, and even cyclic references.
  2. The serialized string is placed on the <mochi-hydratable-island> custom element as a props attribute. (When two islands share the exact same payload, it gets hoisted into a shared <script type="application/json"> block and the attribute becomes a props-ref pointer instead.)
  3. In the browser, the custom element's connectedCallback reads that string and runs devalue.parse on it, reconstructing the rich types.
  4. The result is handed to Svelte's hydrate(...) as the component's props — and the child takes over.

Try it: view source on this page (or inspect the mochi-hydratable-island element) and you'll see the serialized props on the props attribute. Functions, class instances, and Symbols can't be serialized — pass a plain-data representation, or compute them after hydration.

``` ### island-props/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/island-props': Mochi.page('./src/demos/island-props/ServerRenderedParent.svelte'), }; ``` ## Demo: lazy ### lazy/Lazy.svelte ```svelte
{#each Array(6) as _, i (i)}

Lazy Island #{i + 1}

{/each}
``` ### lazy/LazyDemo.svelte ```svelte
#{index} {#if isBrowser} Hydrated! {rand} {:else} Waiting to hydrate... {/if}
``` ### lazy/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/lazy': Mochi.page('./src/demos/lazy/Lazy.svelte'), }; ``` ## Demo: login ### login/EnhancedLoginForm.svelte ```svelte {#if currentUser}

Signed in as {currentUser}.{isHydratable ? ' No page reloads happened on the way here.' : ''}

{:else} {/if} ``` ### login/Login.svelte ```svelte

The same login form, rendered twice. With {'{@attach enhance(...)}'} the submission goes over fetch and the UI updates in place. Without it, the browser submits natively and Mochi re-renders the page.

With {'{@attach enhance(...)}'}

Plain HTML

``` ### login/routes.ts ```ts import { Mochi, fail, redirect, success, getRequestContext } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; import { createSessionToken, verifySessionToken } from './session'; const SESSION_COOKIE = 'mochi_login_session'; export const routes: Record = { '/demos/login': Mochi.page('./src/demos/login/Login.svelte', { serverProps: () => { const { cookies } = getRequestContext(); const session = verifySessionToken(cookies.get(SESSION_COOKIE)); return { currentUser: session?.username ?? null }; }, actions: { default: async ({ formData, cookies }) => { const username = String(formData.get('username') ?? '').trim(); const password = String(formData.get('password') ?? ''); if (!username) { return fail(400, { error: 'Username required', username }); } if (password !== 'hunter2') { return fail(401, { error: 'Bad credentials', username }); } const { token, maxAgeSec } = createSessionToken(username); cookies.set(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'Lax', maxAge: maxAgeSec, }); return success({ username }); }, logout: async ({ cookies }) => { cookies.delete(SESSION_COOKIE, { path: '/' }); return redirect(303, '/demos/login'); }, }, }), }; ``` ### login/session.ts ```ts import { createHmac, timingSafeEqual } from 'node:crypto'; import { getMochiConfig } from 'mochi-framework'; const CONTEXT = 'mochi-demo-session'; const DEFAULT_MAX_AGE_SEC = 7 * 24 * 60 * 60; function sign(data: string): string { const { secretKey } = getMochiConfig(); return createHmac('sha256', secretKey).update(CONTEXT).update(':').update(data).digest().subarray(0, 16).toString('base64url'); } export interface SessionData { username: string; exp: number; } export function createSessionToken(username: string, maxAgeSec: number = DEFAULT_MAX_AGE_SEC): { token: string; maxAgeSec: number; exp: number } { const exp = Math.floor(Date.now() / 1000) + maxAgeSec; const payload = Buffer.from(JSON.stringify({ username, exp })).toString('base64url'); return { token: `${payload}.${sign(payload)}`, maxAgeSec, exp }; } export function verifySessionToken(token: string | undefined): SessionData | null { if (!token) { return null; } const dot = token.lastIndexOf('.'); if (dot === -1) { return null; } const payload = token.slice(0, dot); const sig = token.slice(dot + 1); const expected = sign(payload); if (sig.length !== expected.length) { return null; } try { if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { return null; } } catch { return null; } let parsed: SessionData; try { parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8')); } catch { return null; } if (typeof parsed.username !== 'string' || typeof parsed.exp !== 'number') { return null; } if (parsed.exp < Math.floor(Date.now() / 1000)) { return null; } return parsed; } ``` ## Demo: nested-components ### nested-components/Level.svelte ```svelte
Level {depth}{tag} {isBrowser ? 'hydrated' : 'SSR only'}
{#if depth < max} {/if}
``` ### nested-components/NestedComponents.svelte ```svelte

mochi:hydrate on the root

One island wraps the whole five-level tree. Mochi serializes the root and its descendants once, ships a single bundle, and Svelte hydrates the entire subtree together. Every button at every depth is interactive.

mochi:defer + mochi:hydrate

The same recursive tree, rendered lazily as a server island. The placeholder ships with the page; the browser fetches the rendered HTML on demand. Because mochi:hydrate is also set, Svelte takes over once the fetched markup lands — same five interactive levels, just delivered later.

Loading nested tree from server
``` ### nested-components/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/nested-components': Mochi.page('./src/demos/nested-components/NestedComponents.svelte'), }; ``` ## Demo: prop-dedup ### prop-dedup/PropDedup.svelte ```svelte

Each group below renders the same component three times with the same props. View source: you'll find three <script type="application/json"> blocks (one per group) and nine <mochi-hydratable-island props-ref="…"> tags pointing at them.

{#each groups as group (group.heading)}

{group.heading}

{/each}
``` ### prop-dedup/SharedPropsCard.svelte ```svelte
{label}
    {#each items as item (item.id)}
  • {item.text}
  • {/each}
``` ### prop-dedup/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/prop-dedup': Mochi.page('./src/demos/prop-dedup/PropDedup.svelte'), }; ``` ## Demo: reload-form-data ### reload-form-data/Guestbook.svelte ```svelte
{#if entries.length === 0}

No entries yet. Be the first to sign.

{:else}
    {#each entries as entry (entry.id)}
  • {entry.name} {formatTime(entry.at)}
  • {/each}
{/if}
{#if errorMessage} {/if}
``` ### reload-form-data/ReloadFormData.svelte ```svelte

Each successful submit appends the name to an in-memory list on the server. The hydrated version refetches /api/guestbook after a successful submit and updates the list in place. The plain version submits natively, the page re-renders with a fresh guestbook serverProp, and the new name shows up after the reload.

With {'{@attach enhance(...)}'}

Plain HTML

``` ### reload-form-data/routes.ts ```ts import { Mochi, fail, success } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; type GuestbookEntry = { id: string; name: string; at: number }; const guestbook: GuestbookEntry[] = []; export const routes: Record = { '/api/guestbook': Mochi.api(({ method }) => { if (method !== 'GET') { return new Response('Method not allowed', { status: 405 }); } return Response.json({ entries: [...guestbook].reverse() }); }), '/demos/reload-form-data': Mochi.page('./src/demos/reload-form-data/ReloadFormData.svelte', { serverProps: () => ({ guestbook: [...guestbook].reverse() }), actions: { guestbookSign: ({ formData }) => { const name = String(formData.get('name') ?? '').trim(); if (!name) { return fail(400, { error: 'Name required' }); } if (name.length > 50) { return fail(400, { error: 'Name too long (max 50 chars)' }); } guestbook.push({ id: crypto.randomUUID(), name, at: Date.now() }); return success({}); }, }, }), }; ``` ## Demo: server-island ### server-island/ServerGreeting.svelte ```svelte { mouseX = e.clientX; mouseY = e.clientY; }} />

Hello, {displayName}! 🏝️

Rendered at {renderedAt}

Mouse position: {mouseX}, {mouseY}

island-id: {islandId}

bigProp: {bigProp}

bigProp length: {bigProp.length}

``` ### server-island/ServerIsland.svelte ```svelte
Loading server island
Loading server island
``` ### server-island/ServerNoProps.svelte ```svelte

Server island without props

Random number generated on server: {randomNumber}

Island ID: {islandId}

``` ### server-island/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/server-island': Mochi.page('./src/demos/server-island/ServerIsland.svelte'), }; ``` ## Demo: server-props ### server-props/ServerProps.svelte ```svelte
Rendered at
{renderedAt}
Random number
{random}
Your User-Agent
{userAgent}

Reload the page to see the values change — each request re-runs the resolver.

``` ### server-props/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/server-props': Mochi.page('./src/demos/server-props/ServerProps.svelte', { serverProps: (req) => ({ renderedAt: new Date().toISOString(), userAgent: req.headers.get('user-agent') ?? 'unknown', random: Math.floor(Math.random() * 10_000), }), }), }; ``` ## Demo: shared-state ### shared-state/CounterButton.svelte ```svelte ``` ### shared-state/SharedState.svelte ```svelte
``` ### shared-state/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/shared-state': Mochi.page('./src/demos/shared-state/SharedState.svelte'), }; ``` ## Demo: streams ### streams/RealtimeClocks.svelte ```svelte
WebSocket {wsStatus}
{wsTime} /ws/time
SSE {sseStatus}
{sseTime} /sse/time
{#if islandId}
island-id: {islandId}
{/if}
``` ### streams/Streams.svelte ```svelte ``` ### streams/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/streams': Mochi.page('./src/demos/streams/Streams.svelte'), '/ws/time': (() => { const intervals = new WeakMap(); return Mochi.ws({ open(ws) { ws.send(new Date().toISOString()); const interval = setInterval(() => { ws.send(new Date().toISOString()); }, 1000); intervals.set(ws, interval); }, message() {}, close(ws) { clearInterval(intervals.get(ws)); intervals.delete(ws); }, }); })(), '/sse/time': Mochi.sse((stream) => { stream.send(new Date().toISOString()); const interval = setInterval(() => { stream.send(new Date().toISOString()); }, 1000); stream.onClose(() => clearInterval(interval)); }), }; ``` ## Demo: tailwind ### tailwind/Tailwind.svelte ```svelte

Tailwind v4, no PostCSS

Compiled by @tailwindcss/node at server startup, scanned by @tailwindcss/oxide, served as a hashed stylesheet linked only on this page.

v4.2
Scoped to /demos/tailwind only
{#each palette as swatch (swatch.name)}
{swatch.name}-500
{/each}
``` ### tailwind/routes.ts ```ts import { Mochi } from 'mochi-framework'; import type { MochiRouteValue } from 'mochi-framework'; export const routes: Record = { '/demos/tailwind': Mochi.page('./src/demos/tailwind/Tailwind.svelte'), }; ```