--- title: 'Welcome' slug: intro ogTitle: 'Welcome to Mochi' description: 'A lightweight, server-first Svelte 5 framework running on Bun that ships client-side JavaScript only for interactive islands.' --- # mochi Mochi is a lightweight, server-first framework for [Svelte 5](https://svelte.dev/) on [Bun](https://bun.sh/) — it renders everything as plain HTML and ships JavaScript only for the components you mark as islands. ## Server-rendered, with island interactivity The websites we visit on the web are mostly static — text, images and links. Only a handful of elements on any given page actually need to be interactive: a search box, a logged-in badge, a comments widget. Mochi reflects this at the core of its design. Mochi sites renders server-side as plain HTML; the interactive pieces are marked with a `mochi:*` directive — each one an _island_ — and ship JS embedded in that HTML. Go ahead, try hydrating the page below and see which components will load JavaScript. The header text, the main column, and the footer ship as HTML and stay that way. The badge and the sidebar nav are wrapped as islands — same SSR HTML on first paint, with JS attached on top. Everything else is zero-JS forever. ## Why would I consider Mochi over SvelteKit? - **Faster sites.** Mochi ships zero client JavaScript by default. SvelteKit code-splits per route but still hydrates the entire page — even purely static content. Mochi only hydrates the components you explicitly mark as islands, which means less JS on first load, better bfcache behavior, and a natural fit for any site. - **Performant hydration.** Avoid hydrating islands until the users scrolls into them with `mochi:hydrate:visible`. Or avoid hydrating at all if the user never scrolls down to that component. Your users will thank you for the faster experience. - **Uses the platform.** Mochi ships with first-class support for View Transitions. No client side router, no state to keep track of between requests. - **No heavy bundler (no Vite).** Uses the lighting-fast Bun bundler, which builds sites with hundreds of routes in seconds. - **Real-time built in.** WebSockets and Server Sent Events are first-class route types — no extra packages or services required. Want to know more? to see a full feature comparison. **Work in progress.** Mochi is a new framework and we're still working on features. Be one of the first ones to try it and report any issues you find! ## Community Questions, bug reports, ideas, or just want to see what others are building? Join the [Mochi Discord](/discord/). --- title: 'Your first Mochi app' slug: your-first-mochi-app description: 'Build your first page with serverProps, selective hydration, and server islands in four steps.' --- ## Your first Mochi app Let's build a small app that exercises every server/client boundary you'll touch in real code. We'll put together a single `/hello` page in four steps, picking up one pillar at a time: [`serverProps`](/docs/defining-routes/) for loading data on every request, and [passing props to islands](/docs/island-props/) so a server-rendered parent can hand values to a hydrated child. Then we'll add [`mochi:hydrate`](/docs/selective-hydration/) for one interactive island while the rest stays zero-JS, and [`mochi:defer`](/docs/server-islands/) for a server island that renders separately from the main request. By the end we'll have a greeting card with a live like button and a personalized welcome that loads in after the page renders. ### Set up You'll need [Bun installed](https://bun.com/docs/installation) (>=1.4.0). Scaffold a new project with the official CLI and pick the **minimal** template when prompted: ```sh bun create mochi@latest my-app # choose: minimal cd my-app bun install bun run dev ``` The scaffold gives you a working app on `http://localhost:3333`, with ESLint and Prettier preconfigured (`bun run lint`, `bun run format`; create-mochi 0.4.0+) — opt out with `--no-eslint` / `--no-prettier`. Its entry point is `src/index.ts`, which boots the server and declares your routes inline in the `Mochi.serve()` call: ```ts // file: src/index.ts (scaffolded) import { Mochi } from 'mochi-framework'; const PORT = Number(process.env.PORT) || 3333; await Mochi.serve({ port: PORT, development: process.env.NODE_ENV === 'development', routes: { '/': Mochi.page('./src/HelloWorld.svelte'), }, }); ``` `src/index.ts` is the single bootstrap file — you'll edit its `routes` object next, then build the Svelte components it points at. ### Step 1 — Register the route Now let's point `/hello` at a Svelte page and give it some data to render. Open `src/index.ts` and replace the scaffolded route inside `routes` with this one. `serverProps` is either a plain object or a `(req, params) => props` resolver — whatever it returns becomes the page component's `$props`. ```ts // file: src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ port: Number(process.env.PORT) || 3333, development: process.env.NODE_ENV === 'development', routes: { '/hello': Mochi.page('./src/Hello.svelte', { serverProps: () => ({ siteName: 'Mochi', renderedAt: new Date().toISOString(), }), }), }, }); ``` The resolver runs on every request, so each reload produces a fresh `renderedAt`. See [Defining routes](/docs/defining-routes/) for the full `serverProps` contract and the other `Mochi.*` route helpers. (The scaffolded `src/HelloWorld.svelte` is now unused — feel free to delete it.) ### Step 2 — The page component Next, let's write the page itself. `Hello.svelte` stays server-only (all `Mochi.page()` entry components are server-only) — it consumes the `serverProps`, renders a static layout, and mounts the two child islands we'll build next. Notice that even though it imports two components that ship JavaScript, this file itself ships zero: the `mochi:` directives where we render the components decide what hydrates. ```svelte

Welcome to {siteName}

Rendered at {renderedAt}

Loading…

``` The `initialLikes={42}` value crosses the server→client boundary. Mochi serializes island props with [`devalue`](/docs/island-props/), so `Date`, `Map`, `Set`, `BigInt`, and cyclic references all survive the trip — not just JSON-safe values. Island props end up serialized into the HTML payload, so they're **visible to the client**. Never pass secrets, API keys, or session tokens this way. ### Step 3 — A hydrated island Now let's give the user something to click! `LikeButton.svelte` is a normal Svelte 5 component — we accept `initialLikes` as a prop, keep a `$state` counter, and bump it on click. ```svelte ``` The `mochi:hydrate` directive lives **where we render the component** in `Hello.svelte`, not inside the island itself. The same component can be mounted statically elsewhere. Reload the page in dev mode and you'll see Mochi's [debug bar](/docs/debug-bar/) pinned to the bottom-right of the page. Open the **Islands** panel — `LikeButton` shows up tagged `mochi:hydrate` with the byte size of its serialized props (the `initialLikes` value), and the crosshair icon next to each row scrolls to and outlines the island on the page. ### Step 4 — A server island Finally, let's add a personalized greeting that doesn't block the rest of the page. We marked `Visitor.svelte` with `mochi:defer` back in Step 2, so it skips the initial SSR pass — the page ships with our `

Loading…

` fallback in its place. The browser then fetches the component _in a separate request_, the server renders it, and the result swaps in. The deferred island fetch is its own request, so `getRequestContext()` inside the island sees the island URL — not the page URL. Read page-specific state in the parent and forward it as a prop. Update `Hello.svelte` to read `?name=` and pass it through to `Visitor`: ```svelte

Welcome to {siteName}

Rendered at {renderedAt}

Loading…

``` ```svelte

Welcome back, {name}!

``` `mochi:defer` lets the call site pass fallback children (our `

Loading…

`) that render in place of the island until the deferred fetch resolves. The framework handles the swap — `Visitor` itself never renders `children`, but we declare it in the prop type so TypeScript accepts the fallback at the call site. The `name` prop rides through the same `devalue` round-trip as `initialLikes`. Try [`/docs/your-first-mochi-app/hello/?name=Alice`](/docs/your-first-mochi-app/hello/?name=Alice) — the main page is identical for every visitor, but the deferred fragment swaps in a personalized greeting. Cookies are an exception worth knowing: the browser sends them along with the island fetch automatically, so `getRequestContext().cookies` inside a server island reads the visitor's cookies without needing the parent to forward them. ### See it live The finished app is running on this site at [**/docs/your-first-mochi-app/hello/**](/docs/your-first-mochi-app/hello/). Click the heart, then try [`/docs/your-first-mochi-app/hello/?name=Alice`](/docs/your-first-mochi-app/hello/?name=Alice) to watch the deferred fragment swap in a personalized greeting. The [debug bar](/docs/debug-bar/)'s **Islands** panel groups the two islands separately: `LikeButton` under hydrated islands as `mochi:hydrate`, and `Visitor` under server islands as `mochi:defer` with a lock icon (server-island props are encrypted before being sent to the client). ### What's next - [Defining routes](/docs/defining-routes/) — `Mochi.page`, `Mochi.api`, `Mochi.ws`, `Mochi.sse`, and the full `serverProps` contract - [Selective hydration](/docs/selective-hydration/) — `mochi:hydrate`, `isHydratable()`, `$props.id()` - [Lazy hydration](/docs/lazy-hydration/) — `mochi:hydrate:visible` for below-the-fold islands - [Server islands](/docs/server-islands/) — `mochi:defer`, encrypted props, and `MOCHI_KEY` - [Passing props to islands](/docs/island-props/) — every type `devalue` can round-trip --- title: 'Coming from SvelteKit' slug: coming-from-sveltekit description: 'Map each SvelteKit concept to its Mochi equivalent.' --- ## Coming from SvelteKit This page maps SvelteKit features to their Mochi equivalents so you can start quickly. Read the feature table first, or [skip to the feature list](#routing). ### Feature comparison ### Routing SvelteKit uses a file-based router. Mochi uses a programmatic `routes` record passed to `Mochi.serve({ routes })`. Each key is a Bun router pattern. Each value is built with `Mochi.page`, `Mochi.api`, `Mochi.ws`, or `Mochi.sse`. ``` // SvelteKit src/routes/+page.svelte → / src/routes/posts/[slug]/+page.svelte → /posts/:slug src/routes/health/+server.ts → /health ``` ```ts // file (Mochi): src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ routes: { '/': Mochi.page('./src/Home.svelte'), '/posts/:slug': Mochi.page('./src/Post.svelte'), '/health': Mochi.api(() => Response.json({ status: 'ok' })), }, }); ``` ### Advanced routing Mochi uses Bun router patterns: `:slug` for a required parameter, `*` for a catch-all. There is no SvelteKit-style `[[optional]]` segment, no `[param=matcher]` syntax, and no `src/params/` directory. Validate a parameter's shape inline. ```ts // file (SvelteKit): src/params/fruit.ts export function match(param: string): param is 'apple' | 'orange' { return param === 'apple' || param === 'orange'; } // then: src/routes/fruits/[name=fruit]/+page.svelte ``` ```ts // file (Mochi): src/index.ts import { Mochi, error } from 'mochi-framework'; await Mochi.serve({ routes: { '/fruits/:name': Mochi.page('./src/Fruit.svelte', { serverProps: (_req, params) => { if (params.name !== 'apple' && params.name !== 'orange') error(404, 'Unknown fruit'); return { name: params.name }; }, }), '/files/*': Mochi.page('./src/Files.svelte'), }, }); ``` A `:param` always captures the whole segment — you can't put literal text beside it. SvelteKit's `profile/@[user]` has no direct equivalent: Bun treats `@:user` as literal text, so `/profile/@:user` never matches. Match `/profile/:user` instead — the param captures the whole segment, sigil included (`params.user === '@bob'`) — and note it _also_ matches `/profile/bob`, silently serving the page at two URLs unless you guard: ```ts // file (Mochi): the SvelteKit route profile/@[user] '/profile/:user': Mochi.page('./src/Profile.svelte', { serverProps: (_req, params) => { if (!params.user.startsWith('@')) error(404); // /profile/bob must not alias /profile/@bob return { username: params.user.slice(1) }; }, }), ``` Register the most specific patterns first. Bun matches in declaration order. ### Layouts SvelteKit's `+layout.svelte` / `+layout.server.ts` have no Mochi equivalent. In SvelteKit, layouts persist across navigations: the client-side router keeps the layout component mounted and only swaps the page slot. That also lets `+layout.server.ts` skip refetching data that is still valid. Mochi has no client-side router, so every navigation is a full page load. There is no component tree to persist and no data to revalidate selectively. Instead, create a wrapper component that accepts `children`, then import it from each page. ```svelte {@render children()} ``` ```svelte {@render children()} ``` There is nothing special about the layout — it is a plain component. SvelteKit applies it for you; in Mochi you import it into the page and wrap the markup yourself: ```svelte

Posts

{#each posts as post} {post.title} {/each}
``` Hydrated islands cannot take `children` — it is a [compile error](/docs/selective-hydration/#no-children-on-hydrate-islands). So never put `mochi:hydrate*` on a layout wrapper; hydrate the interactive components inside it instead. Share the data that SvelteKit's `+layout.server.ts` would have loaded through a common helper, then spread the result into each route's `serverProps`: ```ts // file (SvelteKit): src/routes/+layout.server.ts export async function load() { return { user: await loadCurrentUser() }; } ``` ```ts // file (Mochi): src/lib/baseProps.ts export async function baseProps() { return { user: await loadCurrentUser() }; } ``` ```ts // file (Mochi): src/index.ts import { Mochi } from 'mochi-framework'; import { baseProps } from './lib/baseProps'; await Mochi.serve({ routes: { '/': Mochi.page('./src/Home.svelte', { serverProps: async () => ({ ...(await baseProps()), posts: await loadPosts() }), }), }, }); ``` Every page that shares a shell imports the layout component explicitly. There is no automatic nesting. This is more verbose than SvelteKit, but it makes the component tree visible at each call site. ### Load functions SvelteKit's `load` becomes `serverProps` on your `Mochi.page` call. It is a plain object or a `(req, params) => props` resolver (sync or async). The result reaches the component as `$props`. ```ts // file (SvelteKit): src/routes/posts/[slug]/+page.server.ts export async function load({ params }) { return { post: await loadPost(params.slug) }; } ``` ```ts // file (Mochi): src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ routes: { '/posts/:slug': Mochi.page('./src/Post.svelte', { serverProps: async (_req, params) => ({ post: await loadPost(params.slug) }), }), }, }); ``` ```svelte

{post.title}

``` The `Mochi.page()` entry component renders on the server, so you can also call data helpers directly inside the component instead of threading every value through props. Client-side interactivity is opt-in per child component with `mochi:hydrate`, `mochi:hydrate:visible`, `mochi:defer`, or `mochi:defer:visible`. You can read `getRequestContext().params` anywhere on the server instead of threading `params` through `serverProps`. A guard redirect inside `load` ports directly: return `redirect(status, location)` from `serverProps` and the page render is skipped (see [Redirecting from serverProps](/docs/defining-routes/#redirecting-from-serverprops); requires mochi-framework 0.10.0). ```ts serverProps: (req) => { if (!currentUser(req)) return redirect(303, '/login'); return { settings: loadSettings() }; }, ``` ### Form actions SvelteKit's `actions` export becomes the `actions` field on `Mochi.page`. The helpers `fail`, `redirect`, and `success` import from `mochi-framework`. A POST matches an `?/` query (or `default` when absent). The action's return value populates a `form` prop on re-render. The action callback receives `{ request, url, server, locals, kind, method, formData, actionName, cookies, params }`. ```ts // file (SvelteKit): src/routes/login/+page.server.ts import { fail, redirect } from '@sveltejs/kit'; export const actions = { default: async ({ request }) => { const formData = await request.formData(); const username = String(formData.get('username') ?? ''); if (!username) return fail(400, { error: 'Username required' }); return { username }; }, logout: () => redirect(303, '/'), }; ``` ```ts // file (Mochi): src/index.ts import { Mochi, fail, success, redirect } from 'mochi-framework'; await Mochi.serve({ routes: { '/login': Mochi.page('./src/Login.svelte', { actions: { default: ({ formData, cookies }) => { const username = String(formData.get('username') ?? ''); if (!username) return fail(400, { error: 'Username required' }); cookies.set('user', username, { httpOnly: true, path: '/' }); return success({ username }); }, logout: () => redirect(303, '/'), }, }), }, }); ``` ```svelte {#if form?.data?.error}

{form.data.error}

{/if} ``` **`form.data`, not `form`.** SvelteKit spreads the `fail()` payload onto the `form` prop itself; Mochi nests it — `form = { ok, action, status?, data }`. Every `form?.error` read in ported code must become `form?.data?.error`, or the value is silently `undefined`. When `actions` is declared, `form` is reserved for the action result. Do not return `form` from `serverProps`. See [Defining routes](/docs/defining-routes/). ### `use:enhance` SvelteKit's `use:enhance` becomes Mochi's `enhance` attachment from `mochi-framework`. The wire format and `submit` callback semantics match closely — `MochiEnhanceResult` mirrors SvelteKit's `ActionResult`. ```svelte
``` ```svelte
``` Mark the surrounding component with `mochi:hydrate*` so the attachment runs in the browser. Attachments run only when Svelte hydrates a component. See [Progressive enhancement](/docs/progressively-enhancing-forms-with-enhance/). ### API routes (`+server.ts`) SvelteKit's `+server.ts` with `GET` / `POST` exports becomes `Mochi.api(handler)`. One handler per route. Branch on `method` inside. The handler receives `{ request, url, server, locals, kind, method, params, cookies }`. ```ts // file (SvelteKit): src/routes/api/users/[id]/+server.ts export async function GET({ params }) { return Response.json(await loadUser(params.id)); } export async function POST({ params, request }) { return Response.json(await createUser(params.id, await request.json())); } ``` ```ts // file (Mochi): src/index.ts import { Mochi, error } from 'mochi-framework'; await Mochi.serve({ routes: { '/api/users/:id': Mochi.api(async ({ method, request, params }) => { if (method === 'GET') return Response.json(await loadUser(params.id)); if (method === 'POST') return Response.json(await createUser(params.id, await request.json())); error(405, 'Method not allowed'); }), }, }); ``` Use `Mochi.page` for normal HTML routes. `Mochi.api` never goes through the error page or `handleError`. See [API routes](/docs/api-routes/). ### `error()`, `redirect()`, `fail()` Same names, imported from `mochi-framework`. `error(status, message?)` throws `MochiHttpError`, defaulting the message to the canonical status text when omitted. `redirect(status, location)` returns from an action or a `serverProps` resolver. `fail(status, data)` and `success(data?)` round-trip through the `form` prop (under `form.data`) or the `enhance` envelope. ```ts // SvelteKit import { error, redirect, fail } from '@sveltejs/kit'; ``` ```ts // Mochi import { error, redirect, fail, success } from 'mochi-framework'; ``` Call `error(status, message)` to signal an HTTP status. A bare `throw new Error()` becomes a 500. ### Error pages (`+error.svelte`) SvelteKit's `+error.svelte` becomes the `errorPage` option on `Mochi.serve()` — one component for the whole app, defaulting to `DefaultError.svelte`. It receives one `error: MochiErrorProps` prop with `status`, `message`, and `stack` (dev only). ```svelte

{page.status}

{page.error.message}

``` ```ts // file (Mochi): src/index.ts await Mochi.serve({ errorPage: './src/Error.svelte', routes }); ``` ```svelte

{error.status}

{error.message}

``` See [Error handling](/docs/error-handling/). ### Hooks (`handle`) SvelteKit's `hooks.server.ts` `handle` export becomes the `handle` option on `Mochi.serve()`. The shape matches: `async ({ event, resolve }) => Response`. `event` carries `{ request, url, server, locals, kind }`. Compose handles with `sequence(...)`. Unlike SvelteKit, `event` itself does not carry `cookies`, `params`, or `getClientAddress` — read those from `getRequestContext()` inside the handler. ```ts // file (SvelteKit): src/hooks.server.ts import type { Handle } from '@sveltejs/kit'; export const handle: Handle = async ({ event, resolve }) => { event.locals.user = await loadUser(event.request); return resolve(event); }; ``` ```ts // file (Mochi): src/handle.ts import type { Handle } from 'mochi-framework'; export const auth: Handle = async ({ event, resolve }) => { if (event.kind === 'asset') return resolve(event); event.locals.user = await loadUser(event.request); return resolve(event); }; ``` ```ts // file (Mochi): src/index.ts import { Mochi, sequence } from 'mochi-framework'; import { auth } from './handle'; await Mochi.serve({ handle: sequence(auth), routes }); ``` ### `resolve` options SvelteKit's `resolve(event, { transformPageChunk, filterSerializedResponseHeaders })` maps to Mochi's `resolve(event, { transformPage, filterResponseHeaders })`. `transformPage({ html, done })` rewrites the HTML body. `filterResponseHeaders(name, value)` keeps or drops a header. ```ts // file (SvelteKit): src/hooks.server.ts export const handle = ({ event, resolve }) => resolve(event, { transformPageChunk: ({ html }) => html.replace('%THEME%', 'dark'), filterSerializedResponseHeaders: (name) => name.toLowerCase() !== 'server', }); ``` ```ts // file (Mochi): src/handle.ts import type { Handle } from 'mochi-framework'; export const stripServer: Handle = ({ event, resolve }) => resolve(event, { transformPage: ({ html }) => html.replace('%THEME%', 'dark'), filterResponseHeaders: (name) => name.toLowerCase() !== 'server', }); ``` ### `handleError` Same name. Configure it as a `Mochi.serve()` option. The hook receives `{ error, event, status, message }` and may return `{ status, message }`, a `Response`, or `void`. ```ts // file (SvelteKit): src/hooks.server.ts import type { HandleServerError } from '@sveltejs/kit'; export const handleError: HandleServerError = ({ error, event }) => { tracker.capture(error, { path: event.url.pathname }); return { message: 'Internal error' }; }; ``` ```ts // file (Mochi): src/index.ts import type { HandleError } from 'mochi-framework'; const handleError: HandleError = ({ error, event }) => { if (error) tracker.capture(error, { path: event.url.pathname }); }; await Mochi.serve({ handleError, routes }); ``` `handleError` never runs for `Mochi.api` failures. Return an error envelope inside the handler instead. ### `event.locals` Same surface. Set `event.locals.x` from middleware. Read it from any server-side code with `getRequestContext().locals`. ```ts // file (SvelteKit): src/routes/profile/+page.server.ts export const load = ({ locals }) => ({ user: locals.user }); ``` ```ts // file (Mochi): src/SomePage.svelte import { getRequestContext } from 'mochi-framework'; const { locals } = getRequestContext(); ``` ### Observability SvelteKit's experimental OpenTelemetry tracing has no direct equivalent. Mochi emits structured lifecycle events on `mochiEvents` — `request`, `ws:*`, `sse:*`, `cache:*`, `action:*`, `server:start`, `server:stop`, and compile-time events. Subscribe to feed tracing, metrics, or a custom logger. ```js // file (SvelteKit): svelte.config.js export default { kit: { experimental: { tracing: { server: true }, instrumentation: { server: true } } }, }; // then write OTel setup in src/instrumentation.server.ts ``` ```ts // file (Mochi): src/index.ts import { mochiEvents } from 'mochi-framework'; mochiEvents.on('request', ({ method, path, status, duration }) => { metrics.timing('http.request', duration, { method, path, status }); }); ``` `consoleLogger()` is the default subscriber for human-readable output. See [Events](/docs/events/). ### Client IP (`getClientAddress`) SvelteKit's `event.getClientAddress()` becomes a method on `getRequestContext()`. Configure `proxy.addressHeader` / `proxy.xffDepth` on `Mochi.serve()` for trusted reverse-proxy hops. ```ts // file (SvelteKit): src/routes/api/whoami/+server.ts export const GET = ({ getClientAddress }) => new Response(getClientAddress()); ``` ```ts // file (Mochi): src/handle.ts import { getRequestContext } from 'mochi-framework'; const ip = getRequestContext().getClientAddress(); ``` Set `proxy.xffDepth` so `getClientAddress()` picks the correct hop, instead of parsing `X-Forwarded-For` yourself. ### CSRF protection Like SvelteKit, Mochi rejects cross-origin form POSTs by default. It checks the Origin header on `POST` / `PUT` / `PATCH` / `DELETE` when the body is a form content type. Configure it with the `csrf` option on `Mochi.serve()` and the `csrf:trustedOrigins`, `csrf:protectedMethods`, `csrf:formContentTypes`, and `csrf:check` filters. ```js // file (SvelteKit): svelte.config.js export default { kit: { csrf: { checkOrigin: true } }, // default }; ``` ```ts // file (Mochi): src/index.ts await Mochi.serve({ csrf: { trustedOrigins: ['https://embed.example'] }, routes, }); ``` Pin trusted origins through `csrf.trustedOrigins` instead of disabling CSRF on a state-mutating endpoint. ### Cookies `event.cookies` becomes `getRequestContext().cookies` (also on the form-action callback as `event.cookies`). Same `get` / `set` / `delete` API and `CookieSerializeOptions`. App-wide defaults live on the `cookie:defaults` filter rather than per call. ```ts // file (SvelteKit): src/routes/login/+page.server.ts export const actions = { default: ({ cookies }) => { cookies.set('session', token, { httpOnly: true, sameSite: 'lax', path: '/' }); }, }; ``` ```ts // file (Mochi): src/handle.ts import { getRequestContext } from 'mochi-framework'; const { cookies } = getRequestContext(); cookies.set('session', token, { httpOnly: true, sameSite: 'Lax', path: '/' }); ``` ### `$app/state` and the `page` store There is no reactive `page` store. Import `url`, `params`, `cookies`, and `locals` directly from `mochi-framework`. `url` is isomorphic — it reads the request context on the server and `window.location` on the client. `params` and `locals` are server-only. ```svelte

{page.url.pathname} — {page.params.slug}

``` ```svelte

{url.pathname} — {params.slug}

``` `url` works on server and client. Guard `params` and `locals` with `isServer`. See [Request context](/docs/request-context/). ### `$app/navigation` (`goto`, `invalidate`, `preloadData`) No equivalent. Mochi has no client-side router, so there is nothing to `goto` into and nothing to `invalidate`. Every navigation is a full HTML round-trip. For form submissions, use `enhance()`. For anything else, set `window.location.href` or call `history.pushState` from a hydrated island. ```svelte ``` ```svelte ``` Listen for `beforeunload` or `popstate` directly. There is no `beforeNavigate` / `afterNavigate` / `onNavigate`. ### Link options (`data-sveltekit-preload-*`) Planned, but not yet available. Mochi has no client router today, so there is nothing to preload code or data into. The browser handles `` clicks natively, and link preloading is on the roadmap. `data-sveltekit-reload`, `data-sveltekit-replacestate`, `data-sveltekit-keepfocus`, and `data-sveltekit-noscroll` likewise have no Mochi attribute. ```html About ``` ```html About ``` ### Snapshots No equivalent. SvelteKit's `snapshot` exists because its client router reuses page components across navigation. Mochi does full page reloads, so the browser's bfcache restores native `` / ` ``` ```svelte ``` ### Shallow routing (`pushState` / `replaceState`) No framework helper. Call `history.pushState` / `history.replaceState` directly from a hydrated island. There is no `page.state` to read back, so store the value alongside the URL or in component state. ```svelte {#if page.state.modal}{/if} ``` ```svelte ``` ### Service workers Planned, but not yet available. There is no `src/service-worker.ts` convention and no `$service-worker` virtual module. Built-in service worker integration is on the roadmap. Register one yourself from a hydrated island if you need offline support today. ```ts // file (SvelteKit): src/service-worker.ts import { build, files, version } from '$service-worker'; const ASSETS = `cache-${version}`; // install / fetch handlers … ``` ```ts // file (Mochi): src/Boot.svelte (hydrated island) if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js'); } // then ship /public/sw.js yourself ``` ### Image optimization (`@sveltejs/enhanced-img`) Supported, with a different split of the work. SvelteKit optimizes at build time through `@sveltejs/enhanced-img`, with runtime transformations available at extra cost through a CDN. Mochi imports local images the same Vite-style way — the import returns `{ src, width, height, format }` and copies the file to a content-hashed URL. Declare the transforms once as [named sizes](/docs/images/) on `Mochi.serve()`. They run on demand in `Bun.Image` behind an encrypted URL, cached to disk with stale-while-revalidate. The same `` works for remote sources, and `placeholder` adds a ThumbHash blur-up with no client JavaScript. ```svelte ``` ```svelte Hero ``` ### Remote functions No equivalent. SvelteKit's type-safe `query` / `command` RPC has no built-in counterpart. Use `Mochi.api(handler)` with a hand-rolled `fetch()`, or `Mochi.page` actions with `enhance()` for form-driven mutations. ```ts // file (SvelteKit): src/routes/likes.remote.ts import * as v from 'valibot'; import { command } from '$app/server'; export const addLike = command(v.string(), async (id) => { await db.sql`UPDATE item SET likes = likes + 1 WHERE id = ${id}`; }); ``` ```ts // file (Mochi): src/index.ts import { Mochi, error } from 'mochi-framework'; await Mochi.serve({ routes: { '/api/like/:id': Mochi.api(async ({ method, params }) => { if (method !== 'POST') error(405, 'POST only'); await db.sql`UPDATE item SET likes = likes + 1 WHERE id = ${params.id}`; return Response.json({ ok: true }); }), }, }); // on the client: await fetch(`/api/like/${id}`, { method: 'POST' }) ``` ### `$env/static/*` and `$env/dynamic/*` None of these virtual modules exist. Bun auto-loads `.env`, so read everything through `process.env.FOO`. What `$env/static/private` really bought you was a build-time error when a private value reached a client-reachable module. In Mochi that job belongs to [`.server.ts` files](/docs/server-only-imports/), which are replaced with a throwing stub in the client build. Mochi's [environment constants](/docs/environment-constants/) (`isServer`, `isBrowser`, `isDev`) branch on render target, but they do **not** keep the untaken branch out of the bundle — reach for them to pick a code path, not to hide a secret. ```ts // SvelteKit import { API_KEY } from '$env/static/private'; import { PUBLIC_API_URL } from '$env/static/public'; ``` ```ts // Mochi const apiKey = process.env.API_KEY; const publicApiUrl = process.env.PUBLIC_API_URL; ``` Bun loads `.env` for you. To send an environment variable to the client, pass it as a prop to a hydrated island. ### `$app/paths` (`asset`, `base`, `resolve`) No equivalent for app links: write them as absolute paths (`/about`) and prefix them yourself when hosting under a sub-path. Framework assets are prefixed for you by `assetPrefix` (default `/_mochi`) — see [Sub-path and static hosting](/docs/deployment-options/#sub-path-and-static-hosting). ```svelte About ``` ```svelte About ``` ### Server-only modules SvelteKit's `.server.ts` suffix carries over — Mochi uses the same convention. There is no `$lib/server` directory equivalent. The suffix is the entire mechanism, applied per file anywhere in your source tree. Every named and default export of a `*.server.ts` file is replaced with a throwing `Proxy` on the client, so the real module body compiles for SSR only. ```ts // file (SvelteKit): src/lib/server/db.ts import { Database } from 'better-sqlite3'; export const db = new Database(':memory:'); ``` ```ts // file (Mochi): src/lib/db.server.ts import { Database } from 'bun:sqlite'; export const db = new Database(':memory:'); ``` ```svelte

SQLite {version}

``` ### `$lib` No virtual `$lib` alias. Add the path to `tsconfig.json` if you want the same ergonomic. ```svelte ``` ```json // file (Mochi): tsconfig.json { "compilerOptions": { "paths": { "$lib/*": ["./src/lib/*"] } } } ``` ### Page options (`ssr`, `csr`, `prerender`) Not configurable per page. Mochi always renders on the server. Client-side JavaScript is opt-in per component with `mochi:hydrate`, `mochi:hydrate:visible`, `mochi:defer`, or `mochi:defer:visible`. There is no prerender / SSG mode — every request renders fresh. The `*.prerender.ts` suffix prerenders a module, not a route; see [Prerendered modules](/docs/prerender/). Trailing-slash policy is global, not per page. Set `trailingSlash: 'never' | 'always'` on `Mochi.serve()` and Mochi registers both forms of every page route, then redirects to the canonical one. Only page routes follow it — `Mochi.api()`, `Mochi.sse()`, `Mochi.ws()` and `Mochi.file()` match the pattern you declared. See [Trailing slash](/docs/trailing-slash/). ```ts // file (SvelteKit): src/routes/about/+page.ts export const prerender = true; export const csr = false; export const trailingSlash = 'always'; ``` ```ts // file (Mochi): src/index.ts await Mochi.serve({ trailingSlash: 'always', routes }); // hydration is per-component: in the page ``` Control hydration with the `mochi:hydrate*` directives at each call site. There is no per-page `ssr` / `csr` / `prerender` export. ### Streaming / deferred data Stream slow work with `mochi:defer` server islands. The deferred component renders out-of-band and swaps in once ready. Its fallback children stay visible until then. ```svelte

Loading…

``` See [Server islands](/docs/server-islands/). ### Adapters Mochi targets Bun through `Bun.serve()`. Containerize the Bun runtime or deploy to a supported serverless platform. See [Deployment](/docs/deployment-options/). ### `vite.config.ts` Mochi uses Bun's bundler and Svelte 5's compiler. Pass preprocessors through the `compile:preprocessors` filter. Tweak the compiler in `svelte.config.js`, which loads automatically (override it with the `svelteConfigPath` option). See [Svelte config](/docs/svelte-config/). ```js // file (Mochi): svelte.config.js export default { compilerOptions: { runes: true }, }; ``` ### See also - [Defining routes](/docs/defining-routes/) — the four `Mochi.*` route helpers. - [Middleware (hooks)](/docs/middleware/) — `Handle`, `sequence`, `resolve` options. - [Error handling](/docs/error-handling/) — `errorPage`, `handleError`, API error envelope. - [Server islands](/docs/server-islands/) — `mochi:defer` and deferred rendering. - [Hydratable values](/docs/hydratable/) — `hydratable(key, fn)`. - [Extensions](/docs/extensions/) — `eventHooks` and `filters`. - [Events](/docs/events/) — the `mochiEvents` bus. - [Cache](/docs/cache/) — `MochiCache` SWR caching. - [Trailing slash](/docs/trailing-slash/) — global `trailingSlash` policy. --- title: 'Defining routes' slug: defining-routes description: 'Register pages, APIs, WebSockets, SSE endpoints, and file routes with the programmatic routes record.' --- ## Defining routes Routes are a `Record` passed to `Mochi.serve({ routes })`. Each key is a Bun router pattern. Each value comes from one of the five `Mochi.*` helpers. ```ts // file: src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ port: 3333, routes: { '/': Mochi.page('./src/Home.svelte'), '/about': Mochi.page('./src/About.svelte', { serverProps: { title: 'About' } }), '/health': Mochi.api(() => Response.json({ status: 'ok' })), '/ws/chat': Mochi.ws({ message(ws, msg) { ws.send(String(msg)); }, }), '/sse/time': Mochi.sse((stream) => { stream.send(new Date().toISOString()); }), }, }); ``` ### Route parameters Use `:name` for a single segment and `*` for a wildcard tail. Read matched values from `getRequestContext().params`. ```svelte

{params.slug}

``` A `:param` always captures the whole segment — you can't put literal text beside it. Bun reads `/profile/@:user` as literal text, so `/profile/@bob` never matches. Match `/profile/:user` instead; the sigil comes along in the param (`params.user === '@bob'`), and `/profile/bob` matches too. Guard and strip inline when you only want the prefixed form: ```ts serverProps: (_req, params) => { if (!params.user.startsWith('@')) error(404); return { username: params.user.slice(1) }; }; ``` ### `Mochi.page` Register an SSR Svelte page with `Mochi.page(componentPath, { serverProps?, actions? })`. `componentPath` resolves relative to the project root. `serverProps` is a plain object or a `(req, params) => props` resolver (sync or async). The resolved object reaches the component as `$props`. ```ts // file: src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ routes: { '/posts/:slug': Mochi.page('./src/Post.svelte', { serverProps: async (_req, params) => ({ post: await loadPost(params.slug), }), }), }, }); ``` For a resolver that hits a slow upstream, wrap the load in a shared cache — see [Caching expensive serverProps](/docs/cache/). `actions` is a `MochiFormActions` map that handles POST submissions to the route. **Do not use `form` as a prop name.** When `actions` is declared, `form` is reserved for the action result. Return any other prop name from `serverProps` to avoid a runtime error. #### Redirecting from serverProps A `serverProps` resolver may return `redirect(status, location)` instead of props — the page render is skipped and the response carries the redirect. Use it for auth gates: ```ts // file: src/index.ts import { Mochi, redirect } from 'mochi-framework'; await Mochi.serve({ routes: { '/settings': Mochi.page('./src/Settings.svelte', { serverProps: (req) => { const user = currentUser(req); if (!user) return redirect(303, '/login'); return { user }; }, }), }, }); ``` The return type is `MochiRedirect`. Returning `fail()` or `success()` from `serverProps` is a runtime error — those are form-action results. ### `Mochi.api` Register a JSON endpoint with `Mochi.api(handler)`. The handler receives a `MochiApiEvent` (`method`, `request`, `url`, `server`, `locals`, `params`, `cookies`) and returns a `Response`. ```ts // file: src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ routes: { '/health': Mochi.api(({ method }) => Response.json({ status: 'ok', method })), }, }); ``` Throw `MochiHttpError` with `error(status, message)` for non-2xx responses. An uncaught throw becomes `500 Internal Server Error`. See [API routes](/docs/api-routes/). ### `Mochi.ws` Register a WebSocket endpoint with `Mochi.ws(handlers)`. `message` is required. `upgrade`, `open`, `close`, and `drain` are optional. Return data from `upgrade` (or `false` to reject) to attach to `ws.data.user`. ```ts // file: src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ routes: { '/ws/chat': Mochi.ws({ open(ws) { ws.subscribe('chat'); }, message(ws, msg) { ws.publish('chat', String(msg)); }, }), }, }); ``` See [WebSocket routes](/docs/websocket-routes/). ### `Mochi.sse` Register a Server-Sent Events stream with `Mochi.sse(handler)`. The handler receives a `MochiSseStream` with `send`, `close`, and `onClose`. ```ts // file: src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ routes: { '/sse/time': Mochi.sse((stream) => { const interval = setInterval(() => stream.send(new Date().toISOString()), 1000); stream.onClose(() => clearInterval(interval)); }), }, }); ``` **Tear down anything you open per connection.** Each client opens its own timers, intervals, and subscriptions. Without a matching `onClose` teardown they keep running after the client disconnects and leak memory. ```ts Mochi.sse((stream) => { const unsubscribe = chat.subscribe((msg) => stream.send(msg)); stream.onClose(unsubscribe); // fires on disconnect or stream.close() }); ``` ### `Mochi.file` Serve one file from disk with `Mochi.file(source)`. `source` is a string path or a `(req, params) => string` resolver (sync or async). Mochi infers `Content-Type` from the file extension and answers `HEAD` automatically. Paths resolve relative to the working directory. Every resolved path must stay inside the app root. A path outside returns `404`. ```ts // file: src/index.ts import { Mochi, error } from 'mochi-framework'; await Mochi.serve({ routes: { '/report': Mochi.file('./files/report.pdf'), '/files/:name': Mochi.file((req, params) => { const name = params.name; if (!name || !/^[a-z0-9-]+$/.test(name)) { error(404, 'Not found'); } return `./files/${name}.pdf`; }), }, }); ``` Mochi reads the file from disk on every request, so files written or deleted at runtime are picked up at once. `Mochi.file` does not support `Range` requests, caching headers, or middleware. Use `Mochi.api` when you need full control over the response. Route params are URL-decoded before they reach your resolver, so `params.name` can contain `../` (for example, from `/files/..%2f..%2fsecret`). Mochi refuses any path that resolves outside the app root, but that guard does not protect private files inside the root, such as `.env`, source, and config. Always validate params against an allow-list or a strict pattern, as above. ### HEAD requests Every `Mochi.page` and `Mochi.api` route answers `HEAD` automatically. Mochi runs the `GET` logic and strips the body. Status and headers match the `GET`, and `Content-Length` is set to the `GET` body length. This also covers static assets and the `404` fallback. `Mochi.sse` is GET-only: a `HEAD` returns `405 Method Not Allowed` (`Allow: GET`) without opening a stream. `Mochi.ws` routes are upgrade-only and do not handle `HEAD`. ### Static files Mochi serves files under `./public` automatically, in development and production, so the directory must ship with your deploy. A user-defined route wins over a same-path public file. See [Serve options](/docs/serve-options/) for `publicDir`. --- title: 'Selective hydration with mochi:hydrate' slug: selective-hydration description: 'Mark components with mochi:hydrate to ship client JavaScript only where you need interactivity.' --- ## Selective hydration with `mochi:hydrate` Components render on the server and ship zero JavaScript. Add `mochi:hydrate` to opt a component into client-side hydration. Everything else stays static HTML. ```svelte ``` Mochi serializes props with `devalue` so the same values are available during hydration. See [Passing props to islands](/docs/island-props/) for the supported types. ### What is an island? An island is any component you mark with a `mochi:*` directive: `mochi:hydrate`, `mochi:hydrate:visible`, `mochi:clientOnly`, `mochi:clientOnly:visible`, `mochi:defer`, or `mochi:defer:visible`. Everything else is server-rendered HTML that ships no JavaScript. The directive decides when and where the island runs. ### Supported import forms Import an island statically from a **relative** `.svelte` / `.md` / `.svx` path in the same file's ` ``` Framework components from `mochi-framework/components` are the one package exception. Put a directive directly on the package import. ```svelte ``` Any other import form is a **compile error**, surfaced on the dev error page and in `mochi-framework build`. The two you are most likely to hit: - **Third-party package imports** (`import { Widget } from 'some-ui-lib'`). Wrap the component in a local `.svelte` file and put the directive on the wrapper. - **Components received through props, variables, or namespaces** (``). An island needs a statically known source file. Use the same wrapper fix. ```svelte ``` These rules apply to every directive family: `mochi:hydrate*`, `mochi:defer*`, and `mochi:clientOnly*`. **Hydration is all-or-nothing per island.** A `mochi:hydrate` directive hydrates the whole subtree, so nesting one hydratable island inside another is a compile error. Mark the outermost component and let it cover everything below. ### No children on hydrate islands A `mochi:hydrate` / `mochi:hydrate:visible` island cannot take children at the call site — the client hydrates from the serialized props alone, and a snippet cannot cross the server→client boundary: ```svelte

This is a compile error.

``` Move the markup inside the component, or pass what it needs as serializable props. A layout-style wrapper that renders `{@render children()}` therefore cannot be a hydrate island — mark the interactive components _inside_ it instead. With `mochi:defer*` and `mochi:clientOnly*`, children are legal and mean something different: the loading fallback. See [Server islands](/docs/server-islands/) and [`mochi:clientOnly`](/docs/client-only/). ### `isHydratable()` `isHydratable()` returns `true` when the calling component belongs to a subtree that will hydrate on this page load — `mochi:hydrate*`, `mochi:clientOnly*`, or `mochi:defer mochi:hydrate` — at any nesting depth. It returns `false` everywhere else (plain SSR, pure `mochi:defer` renders, emails). Use it to branch SSR-only fallback behavior. ```svelte {#if hydratable} {:else} {count} {/if} ``` Like `getContext`, call `isHydratable()` during component initialization — at the top level of the ` ``` Each instance gets its own id, so repeating the same island never produces duplicate DOM ids. It also works inside server islands: their ids are namespaced so a deferred fragment cannot collide with ids already on the page. ### `mochi:hydrate:visible` Use `mochi:hydrate:visible` to defer hydration until the component scrolls into view. The component still server-renders. Only its JavaScript and CSS load on first intersection. ```svelte ``` Pass `rootMargin` to start loading before the component enters the viewport. See [Lazy hydration](/docs/lazy-hydration/). A `:visible` island loads its CSS with its bundle on intersection, not in the initial page ``. It can briefly render unstyled. Use `mochi:hydrate` for anything that must look correct on the initial SSR load. ### `mochi:clientOnly` Use `mochi:clientOnly` to skip SSR entirely. Mochi mounts the component in the browser only, with an optional fallback snippet as the SSR placeholder. See [Client-only components](/docs/client-only/). ```svelte ``` Add `:visible` to defer the browser mount until the placeholder scrolls into view, with the same `rootMargin` option. ```svelte ``` ### `mochi:defer` Use `mochi:defer` to render the component in a separate request after the page ships. Combine it with `mochi:hydrate` to also hydrate the deferred markup. See [Server islands](/docs/server-islands/). ```svelte ``` Add `:visible` to defer the fetch until the placeholder scrolls into view. ```svelte ``` --- title: 'Client-only components with mochi:clientOnly' slug: client-only description: 'Skip SSR and mount a component in the browser with mochi:clientOnly.' --- ## Client-only components with `mochi:clientOnly` Add `mochi:clientOnly` to a component that must never render on the server. SSR emits an empty island wrapper. In the browser Mochi mounts the component. Use it for components built on browser-only APIs: `window`, canvas, `localStorage`, `requestAnimationFrame`, third-party browser SDKs. ```svelte ``` Props work like `mochi:hydrate` — serialized with `devalue` and embedded into the HTML. See [Passing props to islands](/docs/island-props/). [`isHydratable()`](/docs/selective-hydration/#ishydratable) always returns `true` here, since the component only ever runs at client mount. For a unique id, use Svelte's `$props.id()`, which is minted fresh in the browser. ```svelte ``` ### Fallback content Pass fallback markup as children. It renders on the server as placeholder content and is removed the moment the component mounts. ```svelte
Loading chart…
``` Type the component's props with `ClientOnlyProps` so `svelte-check` accepts the fallback children: ```svelte ``` The fallback children are an SSR placeholder only. They do not reach the component at runtime, so do not render `children` inside a client-only component. Keep the fallback to static markup. Do **not** put `mochi:*` islands inside it — the fallback is wiped from the DOM when the component mounts. ### Lazy client-only with `mochi:clientOnly:visible` Defer the browser mount until the wrapper scrolls into the viewport. The component still never renders on the server. Its JavaScript and CSS are fetched and mounted only when the placeholder intersects the viewport. ```svelte ``` Provide fallback children as the placeholder. They reserve space and give the observer something to watch until the component mounts. ```svelte
Loading chart…
``` ### Server-side APIs are unavailable The component never runs on the server, so `getRequestContext()`, `cookies`, and `hydratable()` server reads are unavailable inside it. Pass server-derived values in as props from the page. ` ## Lazy hydration with `mochi:hydrate:visible` Defer hydration until a component scrolls into the viewport. The component still renders on the server on every request. Its JavaScript and CSS are fetched only when the wrapper intersects the viewport. ```svelte ``` Pass an options object to start loading before the element enters the viewport. Mochi forwards `rootMargin` to `IntersectionObserver`. ```svelte ``` The default `rootMargin` is `'0px'` — hydration fires the moment the island's first child crosses the viewport edge. **A lazy island's CSS loads lazily too.** Mochi fetches the stylesheet with the JavaScript on intersection, so the island can briefly render unstyled. Put critical above-the-fold styles in the page shell, or use `mochi:hydrate` for anything that must look right before it scrolls into view. ### Combining with `mochi:defer` Stack `mochi:defer mochi:hydrate:visible` to defer both rendering and hydration. The placeholder ships with the page. The SSR HTML streams in when the deferred fetch resolves. The JavaScript loads only after the rendered island scrolls into view. ```svelte ``` See [Selective hydration](/docs/selective-hydration/) for `mochi:hydrate` and [Server islands](/docs/server-islands/) for `mochi:defer`. --- title: 'Server islands with mochi:defer' slug: server-islands description: 'Render components after initial page load by fetching their HTML from the server with mochi:defer.' --- ## Server islands with `mochi:defer` Mark a component with `mochi:defer` to skip it during the initial SSR pass and render it on demand from a dedicated endpoint after the page loads. Use it for personalized fragments such as avatars and cart counts that would otherwise block the surrounding HTML from being cached. ```svelte ``` Children of a deferred component become the fallback shown until the island resolves. ```svelte
Loading...
``` Import a deferred component statically from a relative `.svelte` / `.md` / `.svx` path. See [Supported import forms](/docs/selective-hydration/#supported-import-forms). A server island is a normal Svelte component with full access to the request context through `getRequestContext()`. Cookies are forwarded automatically because the fetch is same-origin. ```svelte

Welcome back, {userName}!

``` ### How it renders The page ships with the fallback in place of the island, plus an encrypted token carrying the island's props. The browser then fetches the rendered HTML from a per-island endpoint under `assetPrefix` (default `/_mochi/island/...`), and Mochi swaps it in over the fallback. A failed fetch retries with exponential backoff (default 9 retries, 1s–5s). Pass `mochi:defer={{ retries: 10 }}` to override. `mochi-framework build` precompiles every server island, so production renders them from the prebuilt bundle instead of compiling on first fetch. ### Combining with hydration Apply `mochi:hydrate` alongside `mochi:defer` to fetch the island on demand and then hydrate it for client-side interactivity. ```svelte ``` **Adding `mochi:hydrate` makes the props client-visible.** A pure `mochi:defer` island keeps its props on the server — the token on the wire is opaque and the endpoint returns only HTML. Hydration needs the raw props on the client, so `mochi:defer mochi:hydrate` echoes the decrypted props back as plaintext. Do not pass server-only secrets to an island you also hydrate. ### Reloading an island with `reloadDeferredIsland` Give a defer a `name`, then re-fetch its server HTML from the browser by calling `reloadDeferredIsland(name)`. Use it to refresh server-rendered content after a mutation without a full page reload. A `name` must be a non-empty string — anything else warns and leaves the island unnamed. ```svelte
Loading...
``` ```ts import { reloadDeferredIsland, reloadDeferredIslandAll } from 'mochi-framework'; await reloadDeferredIsland('cart'); // re-fetches, resolves once swapped in await reloadDeferredIslandAll(); // reloads every named defer on the page ``` `deferReloadState(name)` returns reactive state for that island, for UI that follows along: ```svelte {#if cart.reloading}{/if} {#if cart.lastReloadOk === false}

Last refresh failed.

{/if}

Refreshed {cart.count} times{#if cart.lastReloaded}, last at {cart.lastReloaded.toLocaleTimeString()}{/if}

``` | Field | | | -------------- | ----------------------------------------------------------------------------------- | | `reloading` | `true` while that island has a fetch in flight — its first load as well as a reload | | `count` | completed reload rounds, successful or not | | `lastReloadOk` | whether the last reload round _fetched_ successfully, `null` before the first | | `lastReloaded` | `Date` the last reload completed, `null` before the first | Islands sharing a name reload together and settle as one round: `count` adds one per round, and `lastReloadOk` is `true` only when every island in the round fetched successfully. You get one shared instance per name, so reading it repeatedly is free — and reading a field outside a component just gives you its current value. `lastReloadOk` reports the fetch, not the render: an island whose component throws still answers with a 200, so it reads `true` while the island shows its `` fallback. Render failures are the boundary's job, not the reload's. **`deferReloadState` must be called from a `.svelte` or `.svelte.ts` file.** Its fields are runes, so Svelte has to compile the call site; calling it from plain server code throws `$state is not defined`. Both return a promise that settles once every matching island has finished re-fetching. Islands sharing a `name` reload together, and a `mochi:defer mochi:hydrate` island unmounts its old component and re-hydrates when the new HTML lands. Reloads on the same island queue behind one another, so a reload issued after a mutation always observes it. `name` works on `mochi:defer:visible` too. A reload fetches immediately regardless of viewport. The viewport trigger queues behind any reload already in flight and stands down once either has delivered content, so the two can never race or double-fetch. Naming an island also opts it out of [nested inlining](#nesting-islands-inside-a-server-island): a reloadable island needs its own placeholder to fetch into. An explicit `inline: true` on a named island is ignored, with a warning. `reloadDeferredIsland` runs in the browser — call it from a hydrated island or other client code. During SSR no islands are mounted, so it resolves immediately and does nothing. **A reload resolves even if the fetch failed.** It reuses the same retry-and-backoff policy as the initial load, so a hard failure only resolves once the retry budget is spent, leaving the previous content in place. Watch `lastReloadOk`, and lower `retries` on islands you invalidate interactively. #### Loading state while reloading A reloading island keeps showing its current content — the fallback children only ever show before the first load — and carries two attributes for the duration: ```html ``` Style `data-reloading` to mark the wait. Island wrappers are `display: contents`, so they generate no box of their own — put the styles on the children: ```css mochi-server-island[data-reloading] > * { opacity: 0.6; } ``` The old content is swapped out only when the new HTML lands, so hydrated children keep their client state for the whole wait, and a failed fetch leaves the island exactly as it was. Size the fallback to match the loaded content, or the swap shifts the page. The wrapper is `display: contents`, so it holds no space of its own while the content is away — the fallback's own box is the only thing keeping the layout still. Giving both a shared `min-height` is usually enough: ```css .card, .card-skeleton { min-height: 3.5rem; } ``` ### Nesting islands inside a server island A server island's content is a full render, so it can contain `mochi:hydrate` islands and further `mochi:defer` server islands. Hydratable children hydrate once the deferred HTML lands. ```svelte ``` Mochi **inlines** nested `mochi:defer` islands into the parent's fetch. When the island endpoint renders `Dashboard`, it renders `Notifications` in-process too, so one request returns the whole chain no matter how deep it nests. The decision happens at render time. A nested island inside `{#if}` or `{#each}` inlines when its branch renders, with the same props the placeholder would seal, so Mochi never renders an unreachable island. If an inlined child throws, it degrades to a fetch placeholder and fetches on its own. The parent's content stays intact. Inlining is capped at 32 expansions per island fetch. A recursive chain and a long `{#each}` list draw from the same budget. Past the cap, children fall back to fetching. Tune the cap per fetch with the `serverIsland:inlineBudget` filter (see [Extensions](/docs/extensions/)). Opt out per call site to keep a child on its own fetch. This helps when a slow child must not delay the parent's content: ```svelte ``` Opt out globally with `inlineNestedIslands`: ```ts Mochi.serve({ inlineNestedIslands: false }); ``` `mochi:defer:visible` children are always exempt. Laziness is their point, so they keep their own viewport-triggered fetch. Mochi delivers CSS for nested islands with the fetched HTML. The host page cannot link it ahead of time, because the island content does not render until the island resolves. Styles apply as soon as the island appears. **`mochi:defer` inside a hydratable subtree is a compile error.** A component marked `mochi:hydrate*` or `mochi:clientOnly` re-renders on the client, where a server island cannot exist. Remove `mochi:defer` from the child — it already renders as part of the parent's island fetch — or remove the hydrate directive from the parent. ### Lazy server islands with `mochi:defer:visible` Defer the fetch until the wrapper scrolls into view, mirroring [`mochi:hydrate:visible`](/docs/lazy-hydration/). ```svelte
Loading...
``` Combine `rootMargin` and `retries`: `mochi:defer:visible={{ rootMargin: '200px', retries: 10 }}`. Combine with `mochi:hydrate` for interactive lazy islands. Provide fallback children so the user has something to scroll past while waiting. ### Props Mochi serializes props with `devalue` — see [Passing props to islands](/docs/island-props/). Server islands also encrypt the payload and pass it as a query parameter. The island's component name is bound as authenticated data, so a token sealed for one island cannot be replayed against another. **Fetch data inside the island, not through props.** Large props inflate the encrypted URL until it trips a runtime warning at ~1800 characters. Use `getRequestContext()` inside the island to fetch data server-side, and pass only identifiers such as ids as props. **Treat server-island rendering as idempotent. Never trigger mutable actions from it.** Tokens are encrypted, not single-use. Once a client has seen a prop permutation, it can re-fetch that island any number of times. A side effect inside the component — incrementing a counter, charging an account, sending an email — fires again on every replay. ### Encryption key Mochi encrypts props with a key derived from `process.env.MOCHI_KEY` (base64url-encoded, any length). Without `MOCHI_KEY`, Mochi generates a random key and logs a warning — fine for local dev, broken across restarts and multi-instance deploys. Generate a key and write it to `.env`: ```sh bunx mochi-framework generate-key ``` **Set `MOCHI_KEY` for any deployment that runs more than one process or survives restarts.** Without a shared key, tokens minted by one instance fail to decrypt on another, and deferred islands fail to load after a restart or rolling deploy. **Never commit `MOCHI_KEY`.** It signs server-island prop URLs. A leak lets attackers forge them. Supply it through your platform's secret store. --- title: 'Passing props to islands' slug: island-props description: 'How Mochi serializes props for hydratable islands, the supported types, and reserved names.' --- ## Passing props to islands Pass props to a component marked `mochi:hydrate`, `mochi:hydrate:visible`, or `mochi:defer` as you would to any Svelte component. Mochi serializes them with [`devalue`](https://github.com/Rich-Harris/devalue) so the same values reach the hydrating client. ```svelte ``` ### Typing props Put the type on the `let { … } = $props()` declaration. For a few props, inline the type: ```svelte ``` For larger or reused shapes, use a `Props` interface: ```svelte ``` Annotate the `let { … }` declaration. Avoid the `$props<{ … }>()` type-argument form. Type snippet props (including `children`) with the `Snippet` interface from `svelte`. Snippets can only be passed between components on the same side of the server→client boundary — inside an island's subtree, or between server-rendered components — never _into_ a hydrate island from the page (see below): ```svelte {@render children()} ``` When a component wraps a native element and forwards its attributes, type the spread with the matching interface from [`svelte/elements`](https://svelte.dev/docs/svelte/typescript#Typing-wrapper-components): ```svelte ``` ### How props travel For `mochi:hydrate*` islands, props ship inline in the page HTML. When several islands share the same payload, it ships over the wire once and the rest reference it, which keeps the page small. For `mochi:defer` server islands, props are encrypted (opaque on the wire) and passed to a per-island endpoint. See [Server islands](/docs/server-islands/). ### Supported types - Plain objects and arrays - Primitives: strings, numbers, booleans, `null` - `Date`, `RegExp`, `Map`, `Set`, `URL`, `URLSearchParams` - `BigInt`, typed arrays (`Uint8Array`, and so on) - `undefined`, `Infinity`, `NaN`, `-0` - Repeated and cyclic references (identity is preserved) ### Unsupported types - Functions - Class instances (only own enumerable properties survive) - `Symbol` - Snippets / `children` — a snippet is a function, so it cannot cross the boundary. Children at a `mochi:hydrate*` call site are a [compile error](/docs/selective-hydration/#no-children-on-hydrate-islands); on `mochi:defer*` and `mochi:clientOnly*` they are the loading fallback instead. ### Detecting hydration To branch on whether the current render will hydrate, call [`isHydratable()`](/docs/selective-hydration/#ishydratable). It works in any component at any nesting depth, with no prop involved. ```svelte ``` `islandId` is a reserved name on every island. Passing it as a literal prop is a compile error, so a component can move between directives without the name changing meaning. For a unique id inside the component, use `$props.id()`. --- title: 'Hydratable values' slug: hydratable description: 'Serialize computed server values into the page so the client reuses them instead of re-running the work.' --- ## Hydratable values (experimental) > `hydratable` support is experimental. Please open an issue if you find problems. Svelte 5's [`hydratable(key, fn)`](https://svelte.dev/docs/svelte/svelte#hydratable) computes a value on the server, serializes it into the page, and reads it back during client hydration. Use it to avoid running the same async work twice when a hydrated component fetches data at the top level. Without it, the function runs on the server and again during hydration: ```svelte

{user.name}

``` With it, the client reuses the server result: ```svelte

{user.name}

``` Mochi wires this up. Any `hydratable()` call inside a `Mochi.page(...)` route or a `mochi:hydrate*` island is collected during SSR and picked up by Svelte's `hydrate()` automatically. Import `hydratable` straight from `svelte`. **Namespace your keys.** Hydratable keys are global per render. Prefix every key with your app or library name (`app:user`, `mylib:cart`) so two callers cannot collide. ### Serialization Mochi serializes values with [`devalue`](https://www.npmjs.com/package/devalue), so `Map`, `Set`, `Date`, `URL`, `BigInt`, and circular references round-trip. Promises work too — Svelte stitches them back together on the client. ### Limitations in Mochi today **Server islands.** A `mochi:defer` server island renders in a separate request, and its serialized values are not merged into the parent page. Keep `hydratable()` calls in the page or in eagerly hydrated islands. **No CSP nonce wiring.** Mochi does not yet pass a `csp.nonce` to Svelte's `render()`, so the inline lookup script is blocked under strict `script-src`. Allow `'unsafe-inline'` for scripts, or wait for nonce support. --- title: 'Server-only imports' slug: server-only-imports description: 'Keep server-only modules like bun:sqlite out of client bundles with the .server.ts convention.' --- ## Server-only imports Any module reachable from a hydratable island gets bundled into the client. To use a server-only library (`bun:sqlite`, `node:fs`, anything that touches the filesystem) from inside an island, put the library plus a thin wrapper in a `*.server.ts` file. On the client, Mochi replaces these files with stubs that throw if used. The real module compiles for SSR only. ```ts // db.server.ts import { Database } from 'bun:sqlite'; const db = new Database(':memory:'); export const getVersion = (): string => (db.query('SELECT sqlite_version() as v').get() as { v: string }).v; ``` ```svelte

SQLite {version}

``` The `.server.ts` (or `.server.js`) suffix is the whole convention — no runtime API, no config. Import with the extension (`./db.server.ts`). Extensionless `./db.server` also works. The sibling convention `*.prerender.ts` goes the other way: the module runs at build time and only its values ship — see [Prerendered modules](/docs/prerender/). ### Types are free A type-only import is erased before the client build resolves anything, so a `.server.ts` file is also the right home for the types describing its data — even for types used inside a hydratable island. ```ts // db.server.ts export interface Row { id: number; title: string; } export const listRows = (): Row[] => db.query('SELECT * FROM rows').all() as Row[]; ``` ```svelte ``` Mochi stubs only value imports. Use `import type` (or `import { type Row }`) so the compiler drops the import instead of resolving it to a stub. **Wrap usage in `hydratable()` or `isServer`.** The stub throws on access. If you call a `.server.ts` export from client-running code (an `onclick` handler, an `$effect`), the page throws at runtime. The `hydratable()` producer function never runs on the client, so wrapping the call there is safe. Read the value once on the server, ship it through `hydratable()` or a prop, and use the resolved value on the client. ### What gets stubbed On the client, every export of a `.server.ts` file throws on any use. The error names the export and its origin file: ``` getVersion from /…/db.server.ts was called on the client; this is a server-only export. ``` ### Server-only components Name a component `*.server.svelte` to keep it SSR-only. It renders on the server like any component, but the client build replaces it with a stub, so it never ships to the browser — even when an island pulls it in through a barrel re-export. ```svelte
{entries}
``` Import it with the extension (`./Changelog.server.svelte`). The framework's own `ViewTransitions` and `RawScript` use this convention. **Don't hydrate a `.server.svelte`.** A `mochi:hydrate*` or `mochi:clientOnly` directive on one is a compile error, and rendering one anywhere deeper inside a hydrated island's subtree logs a build warning — the client stub would throw at hydration. `mochi:defer` (without also-hydrate) stays fine: a deferred island renders on the server only. Only the default (component) export is stubbed. For server-only _values_, use a `.server.ts` file. ### Unsupported - `export * from './x'` — Mochi warns at build time. Declare named exports in the `.server.ts` file directly. --- title: 'Progressively enhancing forms with enhance' slug: progressively-enhancing-forms-with-enhance description: 'Progressively enhance HTML forms to submit over fetch when JavaScript is available.' --- ## Progressively enhancing forms with enhance `enhance` is a Svelte attachment that progressively enhances `
`. The same server action runs whether JavaScript is available or not. With `{@attach enhance(...)}` the client submits over `fetch`, and the server returns a JSON `MochiEnhanceResult` envelope with no full-page reload. ```svelte
``` Place the form inside a hydrated island (`mochi:hydrate`, `mochi:hydrate:visible`, or `mochi:defer mochi:hydrate`). When hydration is skipped, the attachment never runs and the form falls back to a native HTML POST. That is the progressive-enhancement contract. `enhance` is a factory. Call it as `{@attach enhance()}` even with no options. Attachments require Svelte 5.29+. ### Wire format `enhance` adds `Accept: application/json` and `x-mochi-action: true` to the POST. The server detects either header 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 }; ``` 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. Mochi encodes `data` 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 per 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)` | The `error` log is not part of the replaceable fallback: it fires on every submission, before any callback runs, so a custom handler that only branches on `success`/`failure` cannot turn a transport or server error into a silent no-op. **The default fallback is intentionally lean.** Mochi has no client-side `page.form` store, `goto`, or `invalidateAll`, so it cannot auto-update component props or re-run server data after a submission. Pass a `submit` callback to react to `failure` or to do anything beyond a redirect. When the same component renders both as a hydrated island and as a plain SSR-only child, call [`isHydratable()`](/docs/selective-hydration/#ishydratable) to skip the SSR `form`-prop peek when the client will take over. ### Submit callback Pass a function. It runs once per submit and may return a result handler that replaces the default fallback for `success`, `failure`, and `redirect` (the `error` log above always fires): ```svelte
``` The result handler receives `{ result, formElement, formData, action, update }`. Call `update({ reset?: boolean })` to re-invoke the default fallback. ### onPending Pass an options object with `onPending` instead of tracking a `pending` flag inside the submit function. It fires `true` right before the fetch and `false` once the result handler settles (or when the submission is cancelled): ```svelte
``` `onPending` fires `false` from 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 `fetch` runs. No callback runs. - `controller.abort()` — cancel an in-flight request. The `AbortError` is swallowed. ### Server-side Declare the action. The same `Mochi.page(path, { actions })` definition serves both the no-JS HTML POST flow and the enhanced JSON flow: ```ts // file: src/index.ts import { Mochi, fail, success } from 'mochi-framework'; await Mochi.serve({ routes: { '/login': Mochi.page('./src/Login.svelte', { actions: { login: ({ formData }) => { 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 as an escape hatch. **Wrap data in `success()` to round-trip it to the client.** A plain return like `return { username }` strips the data on the enhanced path, and the result handler receives 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 you roll your own `onsubmit` instead of `{@attach enhance(...)}`: ```svelte ``` ### When to use enhance Use `enhance` when the action's outcome should update the UI without a navigation flicker — interactive forms, optimistic patterns, inline validation. Use a plain `
` when the action ends in a redirect anyway and the JavaScript bundle is not worth shipping. --- title: 'Why Bun?' slug: why-bun description: 'Why Mochi chose Bun as its runtime and which Bun APIs the framework uses.' --- ## Why Bun? Mochi delegates subsystem complexity to the Bun runtime. Instead of maintaining a bundler, an HTML parser, a router, database drivers, compression, and hashing as separate packages, Mochi calls Bun's standard library. Bun maintains those components. Mochi calls them. Mochi ships about 10 runtime dependencies. It uses an external dependency when the dependency earns its place and improves the developer experience. The goal is an opinionated, batteries-included toolkit for building complex web apps. ### What Mochi uses from Bun - `Bun.build()` — Mochi's fast bundler, which builds sites with hundreds of routes in seconds. - `Bun.serve()` — the HTTP and WebSocket server behind `Mochi.serve()`. - `bun:sqlite` and `bun:sql` — zero-dependency SQLite and PostgreSQL for app data. - Native `.ts` execution and auto-loaded `.env` — TypeScript runs directly under `bun run`. ### Batteries included Building on Bun's standard library lets Mochi ship this much out of the box. Here is how the surface compares to SvelteKit: ### On the horizon As Bun adds features, Mochi gains new abilities. For example, `Bun.Image()` powers on-the-fly image resizing. --- title: 'HTTP streaming' slug: http-streaming description: 'Mochi renders each page as one complete document. Use SSE, WebSockets, and server islands for anything that arrives later.' --- ## HTTP streaming Mochi renders each page as one complete document, then ships the HTML as a single `text/html` response. A page holds its response until everything it awaits resolves. A 2-second upstream call delays the whole page by 2 seconds. Push slow or personalized work to the tools below so the shell ships immediately. ### Streaming primitives - `Mochi.sse(handler)` — Server-Sent Events. `stream.send(...)` pushes events as they happen. - `Mochi.ws(handlers)` — WebSockets over the `Bun.serve` upgrade. Bidirectional, message by message. Use these to layer real-time UI on top of a rendered base page. ### Keeping pages fast - **Server islands** (`mochi:defer`) load slow or personalized fragments out-of-band after the shell ships. The island fetches itself once the browser sees the placeholder. - **Visible hydration** (`mochi:hydrate:visible`) keeps the initial JavaScript payload small. - **Shared HTTP cache** (Cloudflare, CloudFront, Fastly, Varnish, nginx) in front of the origin makes render time irrelevant for the cacheable case. A server island can stay uncached behind a cached shell — see [Cache](/docs/cache/). - **[`compress()`](/docs/middleware/#compress) streams** every encoding it offers (gzip, zstd, deflate) through a `CompressionStream`, so a compressed response stays chunked. --- title: 'Environment constants' slug: environment-constants description: 'Constants for branching on render target (isServer, isBrowser), dev mode (isDev), and the build itself (isBuilding).' --- ## Environment constants Import these from `mochi-framework` to branch on render target or dev mode: ```ts import { isServer, isBrowser, isDev } from 'mochi-framework'; ``` Inside compiled code — `.svelte`, `.svelte.[jt]s`, and any `.ts` they import — Mochi substitutes a per-bundle module, so each constant is a literal boolean fixed when that bundle is built. In the server build `isServer` is `true` and `isBrowser` is `false`; in the client bundle the values are reversed. Everywhere else — `src/index.ts`, `routes.ts`, a `.server.ts` reached from them — they are ordinary exports of the package, read at runtime. **These do not keep code out of the client bundle.** The constant is a literal, but Bun does not fold it across module boundaries, so the untaken branch is still bundled — it just never runs. To keep a server-only implementation out of the browser entirely, put it in a [`.server.ts` file](/docs/server-only-imports/), which Mochi replaces with a throwing stub in the client build. ### `isServer` `true` during server-side rendering, `false` in the browser. ```svelte ``` ### `isBrowser` `true` in the client bundle, `false` on the server. Use it to gate browser-only APIs (`window`, `document`, `IntersectionObserver`). ```svelte ``` ### `isDev` `true` in [development mode](/docs/development-mode/), which is `NODE_ENV=development`. Identical on server and client builds. ```ts // file: src/lib/log.ts import { isDev } from 'mochi-framework'; export function trace(msg: string) { if (isDev) console.log('[trace]', msg); } ``` Because it comes from the environment, `isDev` is correct from the first line of your entry — including in module top-level code, which runs before `Mochi.serve()` does. ```json // file: package.json { "scripts": { "dev": "NODE_ENV=development bun src/index.ts", "start": "bun src/index.ts" } } ``` Overriding the mode with `Mochi.serve({ development })` is the one way to make them disagree: top-level code has already run by then, so it saw the environment's answer, not your override. Mochi warns at boot when an override contradicts `NODE_ENV=development`, since dev-only top-level branches will have run inside a production process. ### `isBuilding` `true` only while `mochi-framework build` runs your `index.ts`, `false` when serving (dev or prod). `mochi-framework build` executes your entry to capture its `Mochi.serve()` options, so top-level side effects in `index.ts` run at build time too. Gate the ones you don't want then — connecting a database, spawning workers, running migrations: ```ts // file: src/index.ts import { Mochi, isBuilding } from 'mochi-framework'; if (!isBuilding) await db.connect(); await Mochi.serve({ routes }); ``` The dev server re-imports `index.ts` on every rebuild to pick up route changes; that import sees `true` too, so gated side effects don't re-run on each save. Inside `.svelte` components it is always `false` — components are compiled but never executed during a build. ## Detecting hydration with `isHydratable()` To branch on whether the client will take over rendering, use `isHydratable()`: it returns `true` when the calling component — at any nesting depth — belongs to a subtree that will hydrate on this page load. Unlike the constants above it is a runtime signal, not a build-time literal, so it lives with the hydration model rather than here. See [Selective hydration](/docs/selective-hydration/#ishydratable) for the full semantics and an example. --- title: 'Request context' slug: request-context description: 'Access the current URL, route params, cookies, and locals from server-side code, plus the isomorphic url export.' --- ## Request context Inside components and server-side helpers, import context values directly from `mochi-framework`: ```ts import { url, params, cookies, locals } from 'mochi-framework'; ``` Each export reads from the current request's context on every property access, so you never thread values through props. ### `url` The current page URL as a standard [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) object. ```svelte

Current path: {url.pathname}

``` `url` is **isomorphic**. On the server it reads the parsed request URL. On the client it reflects the current browser URL, including after `pushState` / `replaceState`. `url` reflects the live browser URL on each access, so a destructured value like `const { pathname } = url` is a snapshot. Access `url.pathname` directly when you need the live value. `url.hash` is always empty during SSR — browsers never send the fragment to the server. On the client the hash is available as expected. ### `params` Route parameters matched by the Bun router. Server-only. ```svelte

{params.slug}

``` ### `cookies` Read and write cookies on the server and the client through one API. See the [Cookies demo](/demos/cookies/). ```svelte ``` ### `locals` Per-request data set by middleware. Server-only. ```ts // file: src/middleware.ts import type { Handle } from 'mochi-framework'; export const auth: Handle = async ({ event, resolve }) => { event.locals.user = await getUser(event.cookies); return resolve(event); }; ``` ```svelte ``` ### `getRequestContext()` Returns the full context object with all fields. Server-only. Prefer the individual exports above unless you need several fields at once. ```ts import { getRequestContext } from 'mochi-framework'; const { url, params, cookies, locals, request, requestId } = getRequestContext(); ``` `url` and `cookies` work on the server and the client. `getRequestContext()`, `params`, and `locals` are server-only and throw in the browser, so guard those branches with `isServer`. The context also carries `isWarmup` — `true` when the request came from [route warmup](/docs/serve-options/#route-warmup) at startup, not a real client. Guard side effects in `serverProps` that should not fire for synthetic warmup hits: ```ts serverProps: async () => { const ctx = getRequestContext(); if (!ctx.isWarmup) await recordVisit(ctx.url.pathname); // skip warmup return { posts: await loadPosts() }; }; ``` --- title: 'API routes' slug: api-routes description: 'Register JSON endpoints with Mochi.api() that receive a request event and return a Response.' --- ## API routes `Mochi.api(handler)` registers a JSON endpoint. The handler receives a `MochiApiEvent` (`method`, `request`, `url`, `server`, `locals`, `params`, `cookies`) and **must** return a `Response` (or a `Promise`). ```ts // file: src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ routes: { '/health': Mochi.api(({ method }) => Response.json({ status: 'ok', method })), }, }); ``` ### `MochiApiEvent` Destructure `params` and `cookies` off the event. They mirror what `Mochi.page` form-action handlers receive: ```ts // file: src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ routes: { '/items/:id': Mochi.api(({ url, params, cookies }) => { const tab = url.searchParams.get('tab') ?? 'overview'; const session = cookies.get('session'); return Response.json({ id: params.id, tab, session }); }), }, }); ``` `getRequestContext()` exposes the same values plus `requestId`, `islandProps`, and `getClientAddress()`. Use it from helper functions that are not passed the event. ### Reading the request body Use the standard `Request` body methods (`json()`, `text()`, `formData()`, `arrayBuffer()`). The body stream can be consumed once. ```ts // file: src/index.ts import { Mochi, error } from 'mochi-framework'; await Mochi.serve({ routes: { '/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 }); }), }, }); ``` **Read the request body once.** Calling `request.json()` (or any body method) a second time throws `TypeError: Body already used`. Await it once, store the result, and reuse the value. ### `json` (response helper) Build a JSON `Response` with the right `Content-Type` through `json(data, init?)` from `mochi-framework`. ```ts import { json } from 'mochi-framework'; Mochi.api(() => json({ ok: true }, { status: 201 })); ``` `init` accepts `status`, `statusText`, and `headers`. Mochi sets `Content-Type: application/json` for you. ### `error` (typed throw) Use `error(status, message?)` to throw a `MochiHttpError` from anywhere inside the handler, including helper functions. The framework catches it and returns the canonical envelope `{ error: { message, status } }`. Omitting `message` fills in the canonical status text (`error(404)` → `Not Found`). ```ts import { error } from 'mochi-framework'; Mochi.api(async () => { const user = await loadUser(); if (!user) error(404, 'Not found'); return Response.json(user); }); // → 404 { "error": { "message": "Not found", "status": 404 } } ``` `error` is typed `: never`, so TypeScript narrows control flow after the call. **Uncaught errors become 500s.** Any throw that is not a `MochiHttpError` is caught, coerced to `500 Internal Server Error` with a generic message, and logged server-side. Use `error(status, message)` to return intended status codes. ### `apiError` (typed return) `apiError(status, message)` returns the same envelope as a plain `Response`, without throwing. Use it when the failure is part of the route's normal control flow. ```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 }); }); ``` ### `MochiHttpError` The error class `error()` throws. Catch it explicitly when you want to inspect or re-shape it. Otherwise let it propagate. ```ts import { MochiHttpError } from 'mochi-framework'; try { await mayThrow(); } catch (err) { if (err instanceof MochiHttpError && err.status === 404) { return apiError(404, 'Gone'); } throw err; } ``` ### Uncaught errors Anything else thrown inside a `Mochi.api` handler — a database failure, a typo, a rejected promise — returns `500 Internal Server Error` with a generic message. Mochi logs the original error and stack. The client never sees them. API routes never render the HTML error page, and `handleError` is **not** called for them. The JSON envelope is the only contract. --- title: 'WebSocket routes' slug: websocket-routes description: 'Register WebSocket endpoints with Mochi.ws() and handle upgrade, open, message, close, and drain events.' --- ## WebSocket routes `Mochi.ws(handlers)` registers a WebSocket endpoint backed by Bun's `ServerWebSocket`. The handler map carries five callbacks — `upgrade`, `open`, `message`, `close`, `drain` — and exposes Bun's pub/sub primitives (`ws.subscribe`, `ws.publish`, `ws.unsubscribe`) on the socket. ```ts // file: src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ routes: { '/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'); }, }), }, }); ``` Only `message` is required. [`trailingSlash`](/docs/trailing-slash/) does not apply to WebSocket routes, so connect to exactly the pattern you declared. ### `upgrade` Runs once per HTTP upgrade request. Return a value to attach to `ws.data.user`, or return `false` to reject the connection. The route's URL params are the second argument. ```ts // file: src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ routes: { '/ws/:room': Mochi.ws<{ userId: string; room: string }>({ upgrade(req, params) { const userId = req.headers.get('x-user-id'); if (!userId) return false; // reject the upgrade return { userId, room: params.room }; }, message(ws, msg) { console.log(ws.data.user.userId, ws.data.user.room, msg); }, }), }, }); ``` **Reject unauthenticated sockets in `upgrade`, not `message`.** Authenticating inside `message` still lets the connection establish. Return `false` from `upgrade` so the client never connects. #### Request context during the handshake `getRequestContext()` works inside `upgrade`, so cookies and `getClientAddress()` are available there. `handle` middleware does not run for WebSocket upgrades, so `locals` is empty. Later callbacks receive only the socket, so derive anything header-based here and return it on `ws.data.user`. ```ts // file: src/index.ts import { Mochi, getRequestContext } from 'mochi-framework'; await Mochi.serve({ proxy: { addressHeader: 'x-forwarded-for', xffDepth: 1 }, routes: { '/ws/chat': Mochi.ws<{ address: string | null }>({ upgrade() { return { address: getRequestContext().getClientAddress() }; }, message(ws, msg) { console.log(ws.data.user.address, msg); }, }), }, }); ``` Behind a reverse proxy every socket shares one peer address, so `ws.remoteAddress` is the proxy rather than the visitor. Rate limiting keyed on it becomes one bucket for your whole site — configure `proxy.addressHeader` and read `getClientAddress()` instead. ### `open` Fires once after a successful upgrade. Use it to subscribe the socket to topics or seed per-connection state. ```ts open(ws) { ws.subscribe('chat'); } ``` ### `message` Fires for every inbound frame. The payload is `string | Buffer` — coerce or decode it before use. ```ts message(ws, message) { ws.publish('chat', String(message)); } ``` **Keep `message` fast.** Slow work inside `message` holds up the next message from the same socket. Hand long tasks off to a queue or background task so the handler returns quickly. ### `close` Fires once when the socket closes, with the close `code` and `reason`. Use it to release per-connection state and unsubscribe from topics. ```ts close(ws, code, reason) { ws.unsubscribe('chat'); } ``` ### `drain` Fires when the socket's send buffer drains after a backpressured `ws.send`. Resume queued writes here. ### `ws.data` Each socket carries a typed `data` object. Mochi reserves the internal fields `__mochiRoutePattern`, `__mochiOpenedAt`, and `__mochiPath`. Your `upgrade` return value is exposed as `ws.data.user`. ```ts Mochi.ws<{ userId: string }>({ upgrade(req) { const userId = req.headers.get('x-user-id'); return userId ? { userId } : false; }, message(ws) { console.log(ws.data.user.userId); }, }); ``` ### Pub/sub Every socket exposes `ws.subscribe(topic)`, `ws.publish(topic, data)`, and `ws.unsubscribe(topic)`. To broadcast from outside a handler, capture the `server` returned by `Mochi.serve()` and call `server.publish(topic, data)`. **`ws.publish` does not echo to the sender.** It delivers only to other subscribers. Call `ws.send` alongside `ws.publish` if the publisher should also receive the message. ### Socket limits `Mochi.serve({ websocket })` passes Bun's socket-level options through to every `Mochi.ws()` route. Mochi owns `open`, `message`, `close`, and `drain`; everything else — `maxPayloadLength`, `idleTimeout`, `backpressureLimit`, `perMessageDeflate` — is yours. ```ts await Mochi.serve({ routes, websocket: { maxPayloadLength: 4 * 1024 }, }); ``` `maxPayloadLength` is the only inbound size bound that runs **before** Bun buffers the frame, and it defaults to 16 MB. It caps what the server allocates while a length check inside `message` caps what you store — set both. ### Lifecycle events Every WebSocket emits `ws:open`, `ws:message`, and `ws:close` on `mochiEvents`. `consoleLogger()` prints them. See [Events](/docs/events/) for the payload shape. --- title: 'Server-Sent Events' slug: server-sent-events description: 'Push real-time updates to clients over a single HTTP connection with Mochi.sse().' --- ## Server-Sent Events Register an SSE stream with `Mochi.sse(handler)`. The handler receives a `MochiSseStream` and the underlying `Request`, and runs once per client connection. ```ts // file: src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ routes: { '/sse/time': Mochi.sse((stream) => { stream.send(new Date().toISOString()); const interval = setInterval(() => { stream.send(new Date().toISOString()); }, 1000); stream.onClose(() => clearInterval(interval)); }), }, }); ``` [`trailingSlash`](/docs/trailing-slash/) does not apply to SSE routes, so connect to exactly the pattern you declared. ### `stream.send(data, options?)` Push one SSE frame to the client. `data` is a string. `options` accepts `event` (named event type) and `id` (last-event-id). ```ts stream.send(JSON.stringify({ ok: true }), { event: 'tick', id: '42' }); ``` ### `stream.close()` End the stream from the server. The connection terminates and every registered `onClose` callback fires. **Call `close()` when a stream is finished.** Leaving a long-running stream open keeps the client listening and holds resources. Call `close()` to release them. ### `stream.onClose(callback)` Register cleanup that runs when the stream ends — whether the server called `close()` or the client disconnected. Use it to clear timers, unsubscribe from event buses, and release per-connection state. **Pair every long-lived resource with `onClose`.** A timer or subscription with no matching cleanup leaks on each disconnect: ```ts const sub = bus.subscribe(onTick); stream.onClose(() => sub.unsubscribe()); ``` ### Events `Mochi.sse` emits `sse:open`, `sse:message`, and `sse:close` on `mochiEvents`. `consoleLogger()` prints them by default. --- title: 'Queues' slug: queues ogTitle: 'Background jobs with Mochi.queue()' description: 'Run background jobs with Mochi.queue(), backed by bun-boss on memory, SQLite, Postgres, or embedded PGlite storage.' --- ## Queues Offload work that should not block a response — sending email, encoding media, calling slow APIs — to a background **queue**. A queue bundles a job channel with the `process` function that consumes it. Both run in your process, backed by [bun-boss](https://github.com/khromov/bun-boss). `Mochi.queue(name, …)` returns a descriptor that is both the declaration and the producer handle. Mount it in the `Mochi.serve({ queues })` array to start its worker; call `.add()` on it from anywhere: ```ts import { Mochi } from 'mochi-framework'; export const emails = Mochi.queue<{ to: string }>('emails', { concurrency: 10, process: async (job) => { await sendEmail(job.data.to); return { sent: true }; }, }); await Mochi.serve({ routes: {/* … */}, queues: [emails], }); // from a page action, an API route, anywhere: await emails.add({ to: 'alice@example.com' }); ``` ### `Mochi.queue()` ```ts const queue = Mochi.queue(name, { process, ...options }); ``` `process` receives a read-only `MochiJob` and returns the job result. Omit it for a queue that only receives jobs — e.g. a [dead-letter](#dead-letter-queues) holding pen. | Field | Type | Notes | | ------------ | -------- | --------------------------------------- | | `id` | `string` | job id | | `data` | `T` | the enqueued payload | | `queue` | `string` | queue name | | `attempt` | `number` | 1-based attempt number (1 on first run) | | `enqueuedAt` | `number` | epoch ms when enqueued | Queue-level options are inherited by every job: `concurrency`, `pollingIntervalSeconds`, [retries](#retries) (`retryLimit`, `retryDelay`, `retryBackoff`, `retryDelayMax`), `expireInSeconds`, `retentionSeconds`, `deleteAfterSeconds`, `deadLetter`, [`worker`](#worker-tuning), [`storage`](#standalone-producers). Every duration is in **seconds**. `on` registers lifecycle listeners: ```ts Mochi.queue('emails', { concurrency: 10, process, on: { completed: (job, result) => log.info(`${job.id} done`), failed: (job, error) => log.warn(`${job.id} failed: ${error.message}`), }, }); ``` Or subscribe on the [`mochiEvents` bus](#observability) (filter by `queue` name) when the listener lives far from the queue declaration. ### Adding jobs The descriptor itself is the producer handle — import it and add. `Mochi.getQueue(name)` resolves the same handle by name, for call sites that should not import the declaring module; it throws for a name that was never declared, or before `Mochi.serve()` mounted its queues. | Method | Returns | Notes | | ------------------------------------------ | ------------------------- | ------------------------------------------- | | `add(data, opts?)` | `Promise` | enqueue one job, resolves its id | | `addBulk(jobs)` | `Promise` | enqueue many in one call | | `addThrottled(data, seconds, key?, opts?)` | `Promise` | at most one job per `seconds` slot per key | | `addDebounced(data, seconds, key?, opts?)` | `Promise` | like throttled, but books the next slot too | Per-job options override their queue-level counterparts: `priority`, `startAfter` (seconds, or a `Date`), `id`, `retryLimit`, `retryDelay`, `retryBackoff`, `retryDelayMax`, `expireInSeconds`. ```ts await emails.add({ to: 'bob@example.com' }, { priority: 10, startAfter: 5 }); await emails.addBulk([{ data: { to: 'a@x.com' } }, { data: { to: 'b@x.com' }, opts: { priority: 10 } }]); ``` **`add()` can resolve `null`.** Passing an explicit `id` makes the add idempotent — a second add with the same id resolves `null` instead of duplicating the job. Throttled and debounced adds resolve `null` when the slot is already taken. A plain `add()` always resolves an id. `addBulk` skips jobs whose explicit `id` already exists, so it can resolve fewer ids than jobs submitted. ### Standalone producers A script whose only job is _enqueueing_ — an external scheduler, a CLI backfill, a migration — can write straight to queue storage. (For recurring work inside the server process, use [scheduled jobs](/docs/scheduled-jobs/) instead.) Give the descriptor `storage` and its first `add()` lazily connects a **producer-only** runtime; tear down with [`Mochi.stop()`](#mochistop): ```ts // enqueue.ts — a standalone producer script import { Mochi } from 'mochi-framework'; const emails = Mochi.queue<{ to: string }>('emails', { storage: { postgres: process.env.DATABASE_URL! }, }); await emails.addBulk(jobs); await emails.stop(); ``` - A producer declaring no options simply ensures the queue exists. Declared options are enforced like everywhere else — [config is code-authoritative](#storage) — and a descriptor-form [`deadLetter`](#dead-letter-queues) is created with its link intact even when the producer runs first on fresh storage. To consume without a server, see [standalone workers](#standalone-workers). - `queue.stop()` stops that queue in this process — its worker deregisters after in-flight jobs finish, and the shared runtime closes once the last active queue stops. [`Mochi.stop()`](#mochistop) remains the whole-app teardown; under `Mochi.serve()` queues stop with the server. - Your app has **one queue storage**. Declare it on the descriptor (`storage`), app-wide via the serve-level [`queueStorage`](#storage) option, or both when they agree — conflicting declarations are a boot error. Standalone, a descriptor without `storage` throws on `add()`. - `Mochi.serve()` inherits the descriptors' storage when `queueStorage` is unset, and a serve on the same storage adopts an already-connected standalone runtime — on a different storage it refuses to start. - `mochi:queuesMounted` fires only under `Mochi.serve()`. For a standalone producer, readiness is the first `add()` resolving. ### Standalone workers `Mochi.worker()` is the consuming counterpart: a process that polls and runs `process` without serving HTTP. `start()` connects to the app's queue storage — from the descriptors, or the worker's own `storage` option — ensures the queues exist, and begins polling: ```ts // worker.ts — a standalone worker script import { Mochi, consoleLogger } from 'mochi-framework'; import { emails } from './queues'; consoleLogger(); const worker = Mochi.worker({ queues: [emails] }); await worker.start(); process.on('SIGINT', async () => { await Mochi.stop(); process.exit(0); }); process.on('SIGTERM', async () => { await Mochi.stop(); process.exit(0); }); ``` Queue config follows the same rule as every other path — [code is authoritative](#storage): a worker creates missing queues with their full declared config ([`deadLetter`](#dead-letter-queues) links included, targets first) and refuses to start when storage disagrees; `Mochi.worker({ queueConfig: 'sync' })` writes the declared config to storage instead. `worker.stop()` deregisters the worker's queues (waiting for in-flight jobs) while the runtime stays up for producing; `Mochi.stop()` tears the runtime down. **Standalone means standalone.** `Mochi.worker()` installs no signal handlers (wire them yourself, as above), fires no hooks or startup milestones, and subscribes no logger — call `consoleLogger()` for the `QUEUE` lines. A process that calls `Mochi.serve()` declares its queues there instead; `start()` refuses to run alongside it. ### Worker tuning The rarely-needed bun-boss fetch options ride along in `worker`, forwarded to the worker verbatim: `orderByCreatedOn`, `priority`, `minPriority`, `maxPriority`, `ignoreStartAfter`, `notifyPollingIntervalSeconds`, `burstWhenReadyExceeds`, `heartbeatRefreshSeconds`. ```ts Mochi.queue('plugin-info', { process: fetchPluginInfo, worker: { orderByCreatedOn: false, minPriority: 10 }, }); ``` Mochi-owned settings (`concurrency`, `pollingIntervalSeconds`, and the per-job settlement contract) win where they overlap. `worker` options apply at fetch time only; stored queue options stay as declared. ### Storage One store serves every queue — declared app-wide via the serve-level `queueStorage` option, or inherited from a [`storage`](#standalone-producers) declared on the descriptors: ```ts await Mochi.serve({ queues: [/* … */], queueStorage: 'memory', // the default // queueStorage: { sqlite: '.db/queue.sqlite' }, // queueStorage: { postgres: process.env.DATABASE_URL }, // queueStorage: { pglite: await PGlite.create('.db/queue-pglite') }, }); ``` | Storage | Survives restarts | Scope | | ---------------------- | ----------------- | ------------------------------------------------ | | `'memory'` | no | single process | | `{ sqlite: path }` | yes | single process, one durable file | | `{ postgres: url }` | yes | shared — multiple processes can work one backlog | | `{ pglite: instance }` | yes (on-disk) | single process, embedded in-process Postgres | See [Persistence](/docs/persistence/) for how queue storage compares to the other stateful Mochi features. Postgres storage installs its tables into a dedicated `mochi_queue` schema on first start, away from your application's tables. The schema name is fixed, so every app sharing one database shares one queue namespace — give each app its own database to keep their queues apart. On durable storage, **declared config is authoritative and storage is a cache of it**. At every boot — `Mochi.serve()`, `Mochi.worker()`, and standalone producers alike — each declared queue is created with its full config if missing, and verified field-by-field against storage if present. A mismatch is a boot error naming the queue, the fields, and both values; an option you leave undeclared is expected to hold its bun-boss default. A declaration with no persisted options at all (`Mochi.queue(name, { storage })`) only asserts the queue exists. To change a queue's config, change the code — then let one deploy write it through: ```ts await Mochi.serve({ queues, queueConfig: 'sync' }); // or MOCHI_QUEUE_SYNC=1 in the environment ``` `'sync'` replaces the mismatch error with a repair: the declared config is written to storage and every changed field is logged. `MOCHI_QUEUE_SYNC=1` forces sync process-wide (standalone producers included) and wins over the option — set it on the one deploy that migrates, or leave `queueConfig: 'sync'` on permanently for code-always-wins. The mismatch error also prints a ready-to-paste `Mochi.boss().updateQueue(…)` call for migrating by hand. **Share one descriptor across processes.** Every process that declares a queue asserts its config — a producer script declaring different options than the server is a boot error, not a silent divergence. Export the descriptor from one module and import it everywhere. The `queue:expireInSeconds` filter counts as declared config too, so processes must register the same extensions. ### PGlite `{ pglite: instance }` is the one storage that takes a live instance instead of a config string. Mochi recommends `{ postgres: url }` for production; PGlite — full Postgres compiled to WASM, running in your process with no server — covers dev and test environments while exercising the same Postgres storage path, including the `mochi_queue` schema, so jobs behave the way they will in production: ```ts import { PGlite } from '@electric-sql/pglite'; const db = await PGlite.create('.db/queue-pglite'); // or PGlite.create() for a throwaway in-memory store await Mochi.serve({ queues: [/* … */], queueStorage: { pglite: db }, }); ``` Passing the instance keeps `@electric-sql/pglite` out of Mochi's dependencies and lets your app share one instance between queue jobs and its own tables. You own the instance, so make sure to close it at shutdown. ### Retries Failed jobs retry **by default**: `retryLimit` is 2, so a job runs up to 3 times before it fails terminally. Set `retryLimit: 0` for exactly-once execution, and `retryDelay`/`retryBackoff` to space attempts out: ```ts Mochi.queue('webhooks', { process: deliverWebhook, retryLimit: 5, retryDelay: 5, // seconds; with retryBackoff it doubles per attempt, with jitter retryBackoff: true, retryDelayMax: 300, }); ``` `job.attempt` in `process` is 1-based, so `job.attempt > retryLimit` is true exactly on the final attempt. ### Dead-letter queues Point `deadLetter` at another queue and a terminally failed job is copied there — same payload — for handling or inspection, while the original stays `failed` in its source queue as an audit trail. Pass the target's **descriptor** and the reference is self-sufficient: whichever process boots first — server, worker, or a lone producer — creates the target before the queue that points at it, link intact: ```ts // No `process`: jobs wait here for inspection. Give it one to handle failures automatically. export const webhooksDlq = Mochi.queue('webhooks-dlq'); export const webhooks = Mochi.queue('webhooks', { process: deliverWebhook, retryLimit: 3, deadLetter: webhooksDlq }); await Mochi.serve({ queues: [webhooks, webhooksDlq] }); ``` A descriptor-form target does not need to be in the `queues` array — it is ensured in storage either way, but only mounted (worker started, handle resolvable) where it is declared. The string form (`deadLetter: 'webhooks-dlq'`) still works when the target is declared in the same array or already exists in storage. A dead-letter _loop_ (A→B→A) cannot be created from scratch — each target must exist before its referrer — though an existing loop that matches the declaration passes. Drain or replay a dead-letter queue through the [escape hatch](#mochiboss): `Mochi.boss().redrive('webhooks-dlq')` moves its jobs back to their source queue. Removing the link is a config change like any other: delete the option from code and migrate — [`sync`](#storage) clears it, or run the `updateQueue(name, { deadLetter: null })` the mismatch error prints. **Resetting a queue.** `Mochi.boss().deleteQueue(name)` removes a queue outright — jobs included, pending backlog and failed-job audit trail alike; the next boot recreates it from the declaration. A queue still referenced as another's `deadLetter` cannot be deleted until its referrers are repointed or deleted first. ### Long-running jobs A job may stay active for `expireInSeconds` (default **900**) before the store assumes the worker died and retries or fails it. Raise it above the **worst-case** runtime of `process`, not the typical one — a job that outlives it is handed out again while the original still runs, firing its side effects twice: ```ts Mochi.queue('transcode', { process: transcodeVideo, expireInSeconds: 3600 }); ``` A deployment can override every queue at once with the [`queue:expireInSeconds`](/docs/extensions/) filter. ### `Mochi.boss()` Everything Mochi does not wrap — fetching and cancelling jobs, `findJobs`, `redrive`, queue stats — is reachable on the shared [bun-boss](https://github.com/khromov/bun-boss) instance: ```ts const stats = await Mochi.boss().getQueueStats('emails'); await Mochi.boss().cancel('emails', jobId); ``` It is available from the [`mochi:queuesMounted`](/docs/extensions/#mochiqueuesmounted) hook onwards (or once a standalone producer has connected) and throws before that, or when no queues are declared. **Queues mount late.** They are created after the `mochi:init` hook and after the server binds, so `Mochi.getQueue()` and `Mochi.boss()` throw if called from `mochi:init`. Add jobs from the `mochi:queuesMounted` hook onwards: the `mochi:ready` hook, or any request handler. ### Observability Queues emit [events](/docs/events/) on `mochiEvents`: `queue:added`, `queue:addedBulk`, `queue:active`, `queue:completed`, `queue:failed`, `queue:error`. The [console logger](/docs/logging/) prints a `QUEUE` line for `added`, `addedBulk`, `completed`, `failed`, and `error` at `warn`. Wire your own metrics: ```ts import { mochiEvents } from 'mochi-framework'; mochiEvents.on('queue:completed', ({ queue, jobId, duration }) => { metrics.timing('queue.job', duration, { queue }); }); ``` `queue:completed` and `queue:failed` fire per attempt, as the processor settles — an immediate `Mochi.boss().findJobs()` from a listener may still see the job `active` for a beat. An `addBulk` emits `queue:added` per inserted job (flagged `bulk: true`) plus one `queue:addedBulk` summary — the console logger prints only the summary, so a 100k-job bulk add logs one line. When a queue's backlog crosses the warning threshold (`warningQueueSize`, default `10000`), Mochi logs a `[queue]` warning that names the offending queue and its current depth — e.g. `[queue] Warning: large queue backlog. Your queue should be reviewed (queue "emails" has 12345 jobs queued)`. ### Dev mode & hot reload `Mochi.serve({ queues })` starts the queue runtime once, so the dev route hot-reload watcher cannot spawn a duplicate consumer. The trade-off: **changes to a queue's `process` function or options do not hot-reload**. Restart the dev server to apply them. ### Shutdown Queues close gracefully on `SIGTERM`/`SIGINT`. In-flight jobs get up to `queueShutdownTimeout` (default 10 seconds) to finish; a job still running after that is failed and follows its queue's retry policy from the store. Raise it for handlers that legitimately run longer than 10s, so a job in flight at shutdown finishes instead of being re-run: ```ts Mochi.serve({ queues, queueShutdownTimeout: 60_000 }); // or Mochi.worker({ queues, queueShutdownTimeout }) ``` It is distinct from `shutdownTimeout`, which bounds the HTTP-server drain. For a worker process with an HTTP port (health checks, metrics), use `Mochi.serve({ queues })` with no `routes` — [`Mochi.worker()`](#standalone-workers) is the serverless alternative: ```ts // worker.ts — run with `bun worker.ts` import { Mochi } from 'mochi-framework'; await Mochi.serve({ queueStorage: { postgres: process.env.DATABASE_URL }, queues: [ Mochi.queue('emails', { process: async (job) => { await sendEmail(job.data.to); }, }), ], }); ``` **Dispatch is instant in-process, polled across processes.** An `add()` from the serving process wakes its worker immediately. Other processes sharing Postgres storage — and deferred or retried jobs everywhere — are picked up on the worker's poll, every `pollingIntervalSeconds` (default 2, minimum 0.5). ### `Mochi.stop()` `Mochi.stop()` runs the same graceful teardown as `SIGTERM`/`SIGINT` — the `mochi:shutdown` hook, queue drain, server stop — without exiting the process, so a finite-lifetime script or test ends naturally. In a [standalone producer](#standalone-producers) process it closes the queue runtime. It is idempotent, and a stopped process cannot `Mochi.serve()` again. ```ts await Mochi.serve({ routes, queues }); // … later, from a test or an embedding script: await Mochi.stop(); ``` --- title: 'Email' slug: email ogTitle: 'Sending transactional email' description: 'Send transactional email with Mochi.email() over SMTP, a custom send function, or Svelte templates rendered to inlined HTML.' --- ## Email Send transactional mail — password resets, verification links, notifications — with `Mochi.email()`. Configure a **transport** once under `Mochi.serve({ email })`, then send from anywhere on the server: a page action, an API route, or a queue job. ```ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ routes: {/* … */}, email: { from: 'noreply@acme.dev', transport: { type: 'smtp', host: 'smtp.acme.dev', port: 587, auth: { user, pass } }, }, }); // from an action, API handler, or queue job: await Mochi.email({ to: 'alice@example.com', subject: 'Reset your password', html: '

Click here to reset.

', }); ``` With no transport configured, `Mochi.email()` uses the **dev** transport in development (captured to an in-memory viewer) and the **log** transport in production (logged only). Neither delivers. Configure a real transport before relying on delivery. ### The message Envelope fields, plus the body: ```ts await Mochi.email({ to: 'alice@example.com', // string or string[] from: 'noreply@acme.dev', // optional — falls back to email.from cc, bcc, replyTo, subject: 'Welcome', component: './src/emails/Welcome.svelte', // the body — see "The body" props: { name: 'Alice' }, attachments: [{ filename: 'invoice.pdf', content: bytes }], headers: { 'X-Entity': 'signup' }, }); ``` `Mochi.email()` resolves the body, fills `from` from `email.from`, normalizes recipients, sends, and resolves to a `MochiEmailResult` (`{ transport, messageId?, accepted?, rejected? }`). Any address field accepts a display name in the standard `Name ` form: ```ts await Mochi.email({ from: 'Acme ', to: 'Alice ', subject, text }); ``` ### The body A message has two parts: - **The HTML part** — an `html` string **or** a `component` (a Svelte template rendered to inlined HTML). If you pass both, `component` wins. - **The text part** — the `text` string. Mochi derives it from the HTML when you omit it, so the message stays multipart. ```ts // HTML + your own plain-text alternative (recommended): await Mochi.email({ to, subject, html: '

Hi

', text: 'Hi' }); // A Svelte component + your own plain-text alternative: await Mochi.email({ to, subject, component: './src/emails/Welcome.svelte', props: { name: 'Alice' }, text: 'Welcome, Alice' }); // HTML only — Mochi derives the text part: await Mochi.email({ to, subject, html: '

Hi

' }); // Plain-text only: await Mochi.email({ to, subject, text: 'Hi' }); ``` Supply `text` yourself when you can. The auto-derived fallback strips tags from your HTML, so a hand-written version usually reads better. ### Transports Set `email.transport` to one of four shapes. Omit it for the environment default: **dev** in development, **log** in production. **SMTP** — delivers over SMTP through [nodemailer](https://nodemailer.com/): ```ts email: { from: 'noreply@acme.dev', transport: { type: 'smtp', host: 'smtp.acme.dev', port: 587, // default 465 when secure, else 587 secure: false, // default: port === 465 auth: { user: '…', pass: '…' }, pool: true, }, } ``` **Custom** — an escape hatch for any HTTP email API (Resend, SES, Postmark) with no SDK. Receives the resolved message. No SMTP library is loaded: ```ts email: { from: 'noreply@acme.dev', transport: { type: 'custom', send: async (msg) => { const res = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { Authorization: `Bearer ${process.env.RESEND_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ from: msg.from, to: msg.to, subject: msg.subject, html: msg.html }), }); const { id } = await res.json(); return { messageId: id }; }, }, } ``` **Dev** (default in development) — captures each message into an in-memory outbox you can browse. Set it explicitly with `{ type: 'dev' }`. **Log** (default in production) — logs a one-line summary. Set it explicitly with `{ type: 'log' }`. ### The dev outbox The `dev` transport stores each message in the dev-server process and serves a viewer at **`/_mochi/email`**. The viewer renders the HTML in a sandboxed iframe, plus the plain-text part, the raw source, recipients, headers, and attachments. When the `dev` transport is active, an envelope icon in the [debug bar](/docs/debug-bar/) links to it.
The dev outbox: a list of four captured messages on the left, and on the right the selected message's from, to and date, an attachment chip, Preview / Text / Source tabs, and the rendered email body
The outbox after sending four messages. The Preview / Text / Source tabs switch between the rendered HTML, the plain-text alternative, and the raw source.
The outbox is in-memory and dev-only. It holds the most recent 100 messages, is wiped on restart, and the `/_mochi/email` route is not registered in production. Leaving `transport` unset gives you the dev/log split automatically. To pick the transport by hand, branch on `NODE_ENV` — this options object is evaluated before `Mochi.serve()` resolves [`isDev`](/docs/environment-constants/#isdev), so read the env var directly here: ```ts await Mochi.serve({ routes: {/* … */}, email: { from: 'noreply@acme.dev', transport: process.env.NODE_ENV === 'production' ? { type: 'smtp', host: 'smtp.acme.dev', port: 587, auth: { user, pass } } : { type: 'dev' }, }, }); ``` ### Svelte templates Author a body as a Svelte component. Pass its path as `component` (like `Mochi.page()`) plus `props`. Mochi renders it and **inlines its scoped CSS** into `style=""` attributes (via [css-inline](https://github.com/Stranger6667/css-inline)) for email-client compatibility. Keep templates in **`src/emails/`**. `mochi-framework build` walks that directory and compiles every `.svelte` under it into the manifest, so production sends need neither the compiler nor your Svelte sources. A template outside `src/emails/` still renders, but the build cannot prebuild it. The first send in each process pays a full compile and logs a manifest-miss warning. Move it into `src/emails/` and rebuild. ```svelte

Welcome, {name}

``` ```ts await Mochi.email({ to: 'alice@example.com', subject: 'Welcome to Acme', component: './src/emails/Welcome.svelte', props: { name: 'Alice' }, }); ``` Email templates always render outside the request context, even when sent from a route action. Request-context APIs (`getRequestContext`, `cookies`, `url`) throw. Pass everything the template needs through `props`. **No islands in emails.** A `mochi:hydrate*` island or a `mochi:defer*` server island — anywhere in the template or in anything it imports — is a hard error. Email clients run no JavaScript. Render the content inline instead. ` ## Persistence A few Mochi features keep server-side state that outlives a single request — queued jobs, cached values, rate-limit counters, spent captcha nonces. Each one defaults to in-memory storage and can be pointed at something durable instead. ### Per feature - **[Queues](/docs/queues/)** — in-memory by default. Point the serve-level `queueStorage` (or a descriptor's `storage`) at a SQLite file (`{ sqlite: path }`), a Postgres database (`{ postgres: url }`), or an embedded PGlite instance (`{ pglite }`). One store serves every queue; Postgres storage is shared, so multiple processes can work one backlog. - **[Cache](/docs/cache/)** — `new MochiCache({ storage })`. Defaults to `MemoryStorage`; `FileStorage` writes one JSON file per entry. Built-in SQLite and Postgres backends are planned; until then any other backend is a `Storage` implementation (`getItem` / `setItem` / `removeItem` / `clear`), with `serialize` / `deserialize` when the backend needs strings. - **[Image cache](/docs/images/)** — the one feature that persists by default: `FileStorage` under `cacheDir`. Pass `image: { storage }` to swap it (e.g. `new MemoryStorage()`); `cacheDir` is then ignored. - **[Rate limiting](/docs/rate-limiting/)** — `rateLimit: { store }`. `rateLimitMemoryStore()` (default), `rateLimitSqliteStore({ path })` and `rateLimitPostgresStore({ url })` all ship with the framework; `MochiRateLimitStore` is the interface for your own. Create the store once and share the instance across routes. - **[Captcha](/docs/captcha/)** — `captcha: { store: 'memory' | 'sqlite' }`, plus `storePath` for the SQLite file. A custom `NonceStore` needs only `consume(nonce, expiresAt)`. ### Choosing a backend | Backend | Survives restart | Shared across instances | Use it when | | ------------- | ---------------- | ----------------------- | --------------------------------------------------------------- | | **In-memory** | No | No | Development, single-process apps, state that's cheap to rebuild | | **File** | Yes | Only on shared storage | Caches whose entries are large blobs (images, API responses) | | **SQLite** | Yes | Only on shared storage | One host, one process — durability without another service | | **Postgres** | Yes | Yes | Several instances behind a load balancer | In-memory state is **per process**. Two instances of your app each get their own rate-limit counters and their own spent-nonce set, so limits effectively multiply and a captcha nonce can be replayed once per instance. Pick a shared backend before you scale out. --- title: 'Scheduled jobs' slug: scheduled-jobs ogTitle: 'Durable scheduled jobs with Mochi.cron()' description: 'Run recurring work on a cron schedule with Mochi.cron(), backed by a durable, multi-node scheduler.' --- ## Scheduled jobs Run recurring work — nightly cleanups, hourly syncs, a weekly digest — on a cron schedule. `Mochi.cron()` declares a job; `Mochi.serve({ cron })` starts it. Jobs are **durable** and **run once across a multi-node setup**: the schedule is persisted and a single node is elected per firing, so scaling to N nodes does not fire a job N times. ```ts // file: src/index.ts import { Mochi } from 'mochi-framework'; import { routes } from './routes'; const cleanup = Mochi.cron('cleanup', '0 3 * * *', async () => { await purgeExpiredSessions(); }); await Mochi.serve({ cron: [cleanup], routes }); ``` The third argument is the handler — a bare function as above, or `{ run, … }` when you need [options](#options). The descriptor is inert until `Mochi.serve()` starts it, so declaring one at module scope is free. ### Storage `cronStorage` sets where schedules and their jobs live: ```ts await Mochi.serve({ queueStorage: { postgres: process.env.DATABASE_URL }, cronStorage: { sqlite: '.db/cron.sqlite' }, // cron on its own store cron: [cleanup], }); ``` - Accepts `memory`, `{ sqlite }`, `{ postgres }`, or `{ pglite }`. - **Defaults to `memory`**, independent of `queueStorage`. Cron always runs on its own instance under its own `mochi_cron` namespace — a Postgres schema, or a table-name prefix on SQLite — so queues and cron never share tables even when pointed at the same store. Pointing both at the **same SQLite file** is safe table-wise but puts two writers on one file. Give cron its own file when both are durable. `memory` (and any per-node store) coordinates the run-once guarantee only **within one process**. For a multi-node deployment, point `cronStorage` at shared storage — Postgres, or a SQLite file on a shared volume. ### Schedules Standard 5-field cron syntax — `minute hour day-of-month month day-of-week` — plus the nicknames `@yearly`, `@monthly`, `@weekly`, `@daily`, `@hourly`. Month and weekday accept names (`MON-FRI`, `JAN`). Resolution is **one minute** — the smallest interval is `* * * * *`. ```ts Mochi.cron('every-15-min', '*/15 * * * *', run); Mochi.cron('weekdays-at-9', '0 9 * * MON-FRI', run); Mochi.cron('nightly', '@daily', run); ``` An invalid expression throws **at declaration**, not at boot, so a typo fails when the module is imported rather than after a deploy. ### Options Instead of a bare handler, pass `{ run, … }`: - `tz` — IANA time-zone name the schedule is read in. Defaults to **UTC** — durable cron reads one zone across every node. - `dev` — set `false` to skip the job when `development: true`. Default `true`. ```ts Mochi.cron('digest', '0 9 * * MON', { tz: 'Europe/Stockholm', dev: false, run: async () => sendWeeklyDigest(), }); ``` ### Runs once, transactionally, across a multi-node setup Each firing is claimed by exactly one node through an atomic database update, and the enqueue is deduplicated by a per-minute key. You do **not** need to hand-roll an idempotency key — the scheduler handles the race, and it corrects for clock skew against database time. This is the reason cron is durable rather than a per-node timer. ### A run is a queue job A scheduled run executes internally as a [queue](/docs/queues/) job named `cron-`, so its lifecycle surfaces through the queue events — `queue:active`, `queue:completed`, `queue:failed` with `queue: "cron-"`. Registration emits one `cron:scheduled` event. A handler that throws is reported through `queue:failed` and logged; the schedule stays registered and runs again at its next occurrence. The handler receives the run: `{ name, schedule, scheduledTime, tz? }`. `scheduledTime` is the epoch ms at which the scheduler claimed the firing — the same value on every retry of that firing, so it works as an idempotency key. ```ts Mochi.cron('sync', '*/15 * * * *', async ({ name, scheduledTime }) => { await syncOnce(`${name}:${scheduledTime}`); }); ``` The `cron-` prefix is reserved: a `Mochi.queue()` name may not start with it, so cron jobs and queues never collide. ### Editing and removing jobs Schedules persist in the database. On each boot Mochi reconciles: it registers the declared jobs and **removes any schedule it manages that is no longer declared**, so deleting a `Mochi.cron()` line cleans up its schedule instead of leaving an orphan that keeps enqueuing jobs no worker consumes. In development, editing the `cron` array re-registers on save — no dev-server restart needed. ### Shutdown The scheduler stops on `SIGTERM`/`SIGINT`, on `server.stop()`, and on [`Mochi.stop()`](/docs/queues/#mochistop). Schedules are **not** removed on shutdown — they are durable and resume on the next boot. --- title: 'Middleware (hooks)' slug: middleware description: 'Intercept and transform requests and responses with SvelteKit-style handle functions.' --- ## Middleware (hooks) Middleware uses `Handle` functions registered through `Mochi.serve({ handle })`. Each handle receives `{ event, resolve }`, mutates `event` as needed, calls `resolve(event)` to continue the chain, and returns the resulting `Response`. ### `Handle` A `Handle` is `async ({ event, resolve }) => Response`. `event` carries `{ request, url, server, locals, kind }`. `resolve(event)` invokes the next middleware or the final route handler and returns its `Response`. ```ts // file: src/handle.ts import type { Handle } from 'mochi-framework'; export const auth: Handle = async ({ event, resolve }) => { if (!event.request.headers.get('Authorization')) { return new Response('Unauthorized', { status: 401 }); } return resolve(event); }; ``` **Await `resolve()` to post-process responses.** To inspect or modify the response, use `const response = await resolve(event)` and return it explicitly. Without `await`, your function completes before post-processing finishes, causing silent data loss and race conditions. ### `event.locals` `event.locals` is a per-request object for passing data between middleware layers and into route handlers. Read it from any server-side context with `getRequestContext().locals`. ```ts // file: src/handle.ts import type { Handle } from 'mochi-framework'; export const attachUser: Handle = async ({ event, resolve }) => { event.locals.user = await loadUser(event.request); return resolve(event); }; ``` ### `event.kind` Every event carries a `kind` that describes what the framework is about to do with the request: | Value | When | | ------------ | ---------------------------------------------------------------------------- | | `'page'` | `Mochi.page` route (GET render or POST form action) | | `'api'` | `Mochi.api` route | | `'asset'` | Framework static asset (`.js` / `.css` client bundle or the dev stats route) | | `'fallback'` | Unmatched URL — passed to your `fetch` handler | | `'error'` | Unmatched URL with no `fetch` configured — framework renders a 404 | `kind` is set once at construction. An error thrown during a `Mochi.page` render stays `kind: 'page'`. Use it to opt out of per-request work for framework assets: ```ts // file: src/handle.ts import type { Handle } from 'mochi-framework'; export const auth: Handle = async ({ event, resolve }) => { if (event.kind === 'asset') return resolve(event); if (!event.request.headers.get('Authorization')) { return new Response('Unauthorized', { status: 401 }); } return resolve(event); }; ``` ### `sequence` Compose multiple handles into one with `sequence(...handlers)`. Handles run in order. The first handle's pre-processing runs first, and its post-processing runs last (nested-middleware semantics). ```ts // file: src/index.ts import { Mochi, sequence } from 'mochi-framework'; import { auth, logging, rateLimit } from './handle'; await Mochi.serve({ handle: sequence(auth, logging, rateLimit), routes: { '/': Mochi.page('./src/Home.svelte'), }, }); ``` ### `resolve(event, opts)` `resolve` accepts an options bag for post-processing the response: - `transformPage({ html, done })` — rewrite the HTML body before it is sent. See [`transformPage`](/docs/transform-page/). - `filterResponseHeaders(name, value)` — return `true` to keep a header, `false` to drop it. ```ts // file: src/handle.ts import type { Handle } from 'mochi-framework'; export const stripServerHeader: Handle = ({ event, resolve }) => resolve(event, { filterResponseHeaders: (name) => name.toLowerCase() !== 'server', }); ``` When composed with `sequence`, `transformPage` runs in **reverse** order (inner handle transforms first, outer wraps the result). `filterResponseHeaders` uses **first-defined-wins** — only the earliest handle's filter applies. ### `compress` Built-in middleware factory for response compression. It negotiates gzip, zstd or deflate from the client's `Accept-Encoding`. Place it innermost in `sequence(...)` so it sees the body produced by the rest of the chain: ```ts // file: src/index.ts import { Mochi, sequence, compress } from 'mochi-framework'; await Mochi.serve({ handle: sequence(auth, logging, compress()), routes, }); ``` Options: - `methods` — the encodings the server is willing to use: `'gzip'`, `'zstd'`, `'deflate'`. Defaults to `['zstd', 'gzip']`. The client's `Accept-Encoding` picks the winner. The array order is only a tiebreak when the client expresses no preference. ```ts sequence(auth, compress({ methods: ['gzip'] })); sequence(auth, compress({ methods: ['zstd', 'gzip'] })); ``` Every encoding streams through one `CompressionStream`, so a chunked SSR response stays chunked and the client sees the first bytes before the handler has finished. Brotli is intentionally not offered — Bun's `CompressionStream("brotli")` is fixed at quality 11, far too slow for per-request SSR — so reach for `zstd` when you want brotli-class ratios without giving up streaming; a client that only accepts `br` is served uncompressed. Brotli returns once Bun's `CompressionStream` accepts a quality level. A `methods` entry this build does not support — `'brotli'`, carried over from an older config — is dropped with a warning at startup, leaving the remaining methods in play. `compress()` is a no-op in development, because the debug bar must inject itself into the HTML after the response is built. In production it adds `Vary: Accept-Encoding` and compresses compressible content types (`text/*`, `application/json`, `application/javascript`, `application/xml`, and others). A response that already declares `Content-Encoding` passes through untouched. Static framework assets also flow through `handle`, so `compress()` covers them. Other body-touching middleware must branch on `event.kind === 'asset'` when it needs to skip framework bundles. ### `noCache` Built-in middleware that defaults `Cache-Control: no-cache` on `page` and `api` responses. A route that sets its own `Cache-Control` is left untouched, so opt-in caching works per route. ```ts // file: src/index.ts import { Mochi, sequence, noCache, compress } from 'mochi-framework'; await Mochi.serve({ handle: sequence(noCache, compress()), routes, }); ``` `asset`, `fallback`, and `error` events pass through unchanged. WebSocket upgrades and SSE streams never reach the middleware. --- title: 'Trailing slash' slug: trailing-slash ogTitle: 'Trailing-slash policy and redirects' description: 'Enforce a consistent trailing-slash policy for your page routes with automatic redirects.' --- ## Trailing slash The `trailingSlash` option on `Mochi.serve()` enforces a consistent trailing-slash policy across your `Mochi.page()` routes. Mochi registers each page under both `/foo` and `/foo/`, then redirects requests to the non-canonical form. `Mochi.api()`, `Mochi.sse()`, `Mochi.ws()` and `Mochi.file()` routes are exempt — see below. ```ts await Mochi.serve({ trailingSlash: 'always', routes, }); ``` ### Policy values | Value | Canonical form | Example redirect | | ---------- | -------------- | -------------------- | | `'never'` | No slash | `/about/` → `/about` | | `'always'` | Trailing slash | `/about` → `/about/` | Default: unset. Neither form is redirected, and only the form you registered is matched. ### Only page routes follow the policy `trailingSlash` never applies to `Mochi.api()`, `Mochi.sse()`, `Mochi.ws()` or `Mochi.file()` routes — no mirroring, no redirect, regardless of policy. Only the exact pattern you declared matches; the other slash form 404s like any unregistered path. ```ts await Mochi.serve({ trailingSlash: 'always', routes: { '/about': Mochi.page(About), // /about → 301 → /about/ '/api/ping': Mochi.api(() => json({ ok: true })), // only /api/ping matches '/sse/time': Mochi.sse(clock), // only /sse/time matches '/ws/chat': Mochi.ws(chat), // only /ws/chat matches }, }); ``` A canonical URL is a navigation concern: it matters for links, crawlers and caches, which is what pages have and what a JSON fetch, an `EventSource` or a WebSocket does not. A raw Bun route value (a bare `Response` or `{ GET }` object) isn't one of these helpers, so it still answers on both forms when a policy is set. ### Redirect status codes | Method | Status | | ----------------------------- | ---------------------- | | `GET`, `HEAD` | 301 Moved Permanently | | `POST` (pages with `actions`) | 308 Permanent Redirect | 308 preserves the request method and body, so `` still works after a redirect. The 308 only happens for pages that declare `actions`: those are the only pages registered for POST. A page without `actions` accepts just GET/HEAD, so a POST to it matches no route — it 404s instead of redirecting. ### Paths that are never redirected - The root path `/` — already canonical. - Paths with file extensions (`.css`, `.js`, `.png`, …) — browsers and CDNs expect exact asset URLs. - Anything that isn't a `Mochi.page()` route. - Paths that match no route — they 404 (or reach your `fetch` fallback) in whichever slash form they arrived, with no canonicalization hop. ### Query strings Mochi preserves query parameters in the redirect target: ``` GET /search/?q=mochi → 301 Location: /search?q=mochi (policy: 'never') GET /search?q=mochi → 301 Location: /search/?q=mochi (policy: 'always') ``` ### Generating canonical links `trailingSlashIt(path)` appends a trailing slash, first stripping any the string already ends with. It preserves a query string or `#fragment` and puts the slash on the path, so you can pass a full URL. Build hrefs with it under `trailingSlash: 'always'` so links point at the canonical URL and skip the redirect hop. Apply it to page URLs only. Every other kind has no slashed form, so `trailingSlashIt('/api/ping')` yields a URL that 404s. ```ts import { trailingSlashIt } from 'mochi-framework'; trailingSlashIt('/docs/intro'); // '/docs/intro/' trailingSlashIt('/docs/intro/'); // '/docs/intro/' trailingSlashIt('/'); // '/' trailingSlashIt('/search?q=mochi'); // '/search/?q=mochi' trailingSlashIt('/docs/intro#install'); // '/docs/intro/#install' ``` It is isomorphic — import it in SSR pages, hydrated islands, and plain `.ts` modules. --- title: 'Rate limiting' slug: rate-limiting description: 'Per-route and global request rate limiting with memory, SQLite, and Postgres stores.' --- ## Rate limiting Add a `rateLimit` config to any `Mochi.page()` or `Mochi.api()` route. It is driven by [`@joint-ops/hitlimit-bun`](https://www.npmjs.com/package/@joint-ops/hitlimit-bun), and the options pass straight through. ```ts '/api/data': Mochi.api(handler, { rateLimit: { limit: 100, window: '1m' }, }), '/pricing': Mochi.page('./src/Pricing.svelte', { rateLimit: { limit: 5, window: '1m' }, }), ``` Mochi keys requests by client IP by default. Over the limit: - **API routes** return a `429` JSON body (`{ hitlimit: true, message, limit, remaining, resetIn }`). - **Page routes** render your [error page](/docs/error-handling/) with status `429`. Enhanced form submissions get JSON, like other form errors. A blocked request never reaches your [`handle` middleware](/docs/middleware/) — the `429` is produced before it runs, like a CSRF rejection — but it still emits the standard `request` event, so it appears in [logging](/docs/logging/). Every limited route's responses carry `RateLimit-*` and `X-RateLimit-*` headers, plus `Retry-After` on a `429`. ### Global default Set `rateLimit` on `Mochi.serve()` to cover every page and API route. Routes inheriting it share **one bucket per key** — a client's hits on any of them count against the same quota. A route's own config replaces the global one with its own bucket. `rateLimit: false` opts a route out. ```ts await Mochi.serve({ routes: { '/api/login': Mochi.api(login, { rateLimit: { limit: 5, window: '1m' } }), // own bucket '/health': Mochi.api(health, { rateLimit: false }), // exempt }, rateLimit: { limit: 1000, window: '1m' }, // everything else }); ``` ### Options Mochi accepts all of hitlimit's options except `logger` (Mochi logs `429`s through its own [request events](/docs/events/)): | Option | Default | | | -------------- | ------------- | ----------------------------------------------------------------------------------------- | | `limit` | `100` | Max requests per window | | `window` | `'1m'` | `'30s'`, `'1m'`, `'1h'`, `'1d'`, or milliseconds | | `key` | client IP | `(req, ctx) => string` — what to bucket by | | `store` | in-memory | `rateLimitSqliteStore(…)`, `rateLimitPostgresStore(…)`, or a custom `MochiRateLimitStore` | | `tiers`/`tier` | — | Named limits + `(req, ctx) => string` tier resolver | | `ban` | — | `{ threshold, duration }` — ban repeat offenders | | `group` | route pattern | `string \| (req, ctx) => string` — bucket namespace; same value → shared bucket | | `skip` | — | `(req, ctx) => boolean` — bypass without consuming quota | | `response` | hitlimit JSON | Custom 429 body (API routes) | | `headers` | all on | `{ standard, legacy, retryAfter }` | | `onStoreError` | `'allow'` | Fail open, or `'deny'` when the store errors | ### Stores `rateLimitMemoryStore()` is the default — zero config, per process. Use SQLite for persistence across restarts, or Postgres for shared state across instances. All three are re-exported from `mochi-framework`, and anything else is a custom `MochiRateLimitStore`: ```ts import { rateLimitMemoryStore, rateLimitSqliteStore, rateLimitPostgresStore } from 'mochi-framework'; rateLimit: { limit: 100, window: '1m', store: rateLimitSqliteStore({ path: './ratelimit.db' }) } rateLimit: { limit: 100, window: '1m', store: rateLimitPostgresStore({ url: process.env.DATABASE_URL }) } ``` See [Persistence](/docs/persistence/) for how these stores compare with the other Mochi features that persist state. Mochi buckets counters by **key within a store**. A route with its own `rateLimit` config also folds its route pattern into the key, so different routes backed by the same database keep separate counters, even when the key resolves to the same value. Run the same route on two servers against one database and both write the same key, so they share a counter — that is how you rate-limit across a fleet. Routes inheriting the [global default](#global-default) share one bucket per key by design. **Overriding the namespace with `group`.** Setting `group` replaces the automatic route-pattern namespace. Give two routes the same `group` and they share one bucket — a single quota across a family of endpoints. Give a route on the global default its own `rateLimit` config to split it into an isolated bucket. Each store instance owns its backend — a DB connection, prepared statements, and a cleanup timer. Create the store **once** and share the instance. Calling `rateLimitSqliteStore({ path })` inline in every route config opens one connection per route to the same file, all fighting over SQLite's single write lock. ```ts const store = rateLimitSqliteStore({ path: './ratelimit.db' }); // one connection… '/api/search': Mochi.api(search, { rateLimit: { limit: 30, window: '1m', store } }), // …own bucket '/api/upload': Mochi.api(upload, { rateLimit: { limit: 5, window: '1m', store } }), // …own bucket ``` **Dev reloads.** Creating a store inline in a route config builds a fresh store on every save while the old one is never closed, leaking a handle per reload. Counters still persist. If the churn bothers you, keep dev on the default memory store and attach the persisted store in production only. ### Keys and proxies The default key is Mochi's **proxy-aware** client address — the same value as [`getClientAddress()`](/docs/request-context/), honouring `proxy.addressHeader` / `xffDepth`. Behind a reverse proxy, configure `proxy` or every client shares the proxy's IP: ```ts await Mochi.serve({ proxy: { addressHeader: 'x-forwarded-for', xffDepth: 1 }, … }); ``` Key by anything else with `key`. It receives the `Request` plus Mochi's [request context](/docs/request-context/), so you can bucket by the proxy-aware IP, cookies, params, or your own identity. It can be `async`. `tier`, `group`, and `skip` receive the same two arguments. ```ts // by API key, falling back to the proxy-aware IP key: (req, ctx) => req.headers.get('x-api-key') ?? ctx.getClientAddress() ?? 'anon' // tiered by plan tiers: { free: { limit: 10 }, pro: { limit: 1000 } }, tier: (req, ctx) => (ctx.locals.plan as string) ?? 'free', ``` **The limiter runs before your `handle` [middleware](/docs/middleware/).** `ctx` is fully populated — `request`, `url`, `params`, `cookies`, `getClientAddress()` — but `ctx.locals` reflects only what ran before the limiter. A `userId` your auth middleware puts on `locals` is **not** visible here. To key by the logged-in user, derive the identity straight from the request inside `key` (decode the session cookie or bearer token). ### Only counting failures `skip` bypasses the limiter without consuming quota. Since the limiter runs before your middleware, re-do the credential check inside `skip` so only rejected attempts spend quota. A brute-force run burns the quota. Someone who knows the password is never throttled. ```ts '/admin': Mochi.page('./src/Admin.svelte', { rateLimit: { limit: 10, window: '15m', ban: { threshold: 3, duration: '1h' }, skip: (req) => credentialsMatch(req.headers.get('Authorization')), }, }), ``` `skip` may be `async`, so it is also a natural place for a tarpit. An `await Bun.sleep(…)` on the failing branch delays the rejection without slowing a valid request: ```ts skip: async (req) => { const header = req.headers.get('Authorization'); // No credentials at all is a browser fetching the 401 challenge, not a guess: // do not stall it, and do not charge it quota. if (!header || credentialsMatch(header)) return true; await Bun.sleep(5000); return false; }, ``` ### Reading usage server-side An allowed request exposes its limiter state on the request context. Render quotas in `serverProps` or any server-side code: ```ts const rateLimit = getRequestContext().rateLimit; // { limit, remaining, resetIn, resetAt, key, group?, tier? } — or undefined if no limiter ran ``` **Not counted:** [warmup](/docs/serve-options/) requests, trailing-slash redirects, and CSRF rejections never consume quota. In dev, `rateLimit` edits apply on save — a route with its own config gets fresh in-memory counters, and routes on the global limiter keep their shared bucket. --- title: 'Transforming HTML with transformPage' slug: transform-page description: 'Rewrite rendered HTML before it is sent to the client with the transformPage callback.' --- ## `transformPage` Pass `transformPage` to `resolve(event, { transformPage })` inside a `Handle` to rewrite the rendered HTML before it ships. It runs once per response, only on `text/html` bodies, with the full HTML string and `done: true`. ```ts // file: src/hooks.ts import type { Handle } from 'mochi-framework'; const greeting: Handle = async ({ event, resolve }) => { return resolve(event, { transformPage({ html }) { return html.replace('{{app.greeting}}', 'Welcome to Mochi!'); }, }); }; ``` ```html
{{app.greeting}}
{{mochi.body}} ``` The callback receives `{ html, done }` and returns `string | undefined | Promise`. Returning `undefined` replaces the body with an empty string. Use it for per-request mutations the shell template cannot express: a request-aware ``, nonce injection, or A/B placeholder swaps. ```ts const lang: Handle = async ({ event, resolve }) => { const locale = event.request.headers.get('accept-language')?.slice(0, 2) ?? 'en'; return resolve(event, { transformPage({ html }) { return html.replace(' **Use `transformPage` for request-dependent values only.** Static markup belongs in `src/shell.html` (or the default shell) so it ships without per-request work.
--- title: 'Error handling' slug: error-handling description: 'Configure a custom error page and control how uncaught errors render to the client.' --- ## Error handling Mochi renders an HTML error page for any uncaught error that escapes a page render: top-level SSR throws, `error(status, ...)` from `serverProps` or actions, malformed form bodies, unknown form actions, and unmatched routes. API routes return a JSON envelope instead. Island-level boundaries are scoped to hydratable islands — see [Error boundaries](/docs/error-boundaries/).
The built-in error page: a large 500 above the message Internal Server Error, a Go home link, and a Stack trace section showing the thrown error
The built-in error page, shown when errorPage is omitted. The stack trace renders only under development: true.
Configure the page with `errorPage` on `Mochi.serve()`. Omit it to use the built-in component. ```ts // file: src/index.ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ errorPage: './src/Error.svelte', routes: { '/': Mochi.page('./src/Home.svelte'), }, }); ``` ### `errorPage` The component receives one `error` prop typed by `MochiErrorProps`. ```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, populated only when `development: true`, else absent | Default behavior without `errorPage`: - Unmatched routes → `404 Not Found`. - Uncaught throws in `serverProps`, page render, or an action handler → `500 Internal Server Error`. - `error(status, message?)` thrown from any of these → that exact `status`, with the message defaulting to the canonical status text when omitted. ### Request context in the error page `getRequestContext()` works inside the error component — `url`, `cookies`, and `locals` behave as on any page, so a shared nav can read the current path. On unmatched-route 404s the context is minimal: `params` is empty because no route matched. ```svelte

{error.status}

No page at {url.pathname}

``` ### `handleError` Fires whenever the error page is about to render. Use it to log, forward to error tracking, or sanitize the message the user sees. ```ts // file: src/index.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.' }; }; await Mochi.serve({ errorPage: './src/Error.svelte', handleError, routes: { '/': Mochi.page('./src/Home.svelte'), }, }); ``` Return one of: - `{ status, message }` — override either field passed to the error component. - a `Response` — short-circuit rendering (useful for redirects). - `void` — keep the defaults. `error` is `null` when the condition did not come from a throw (unmatched routes, unknown form actions). Inspect it before forwarding so benign 4xx cases do not page on-call. **API routes bypass `handleError`.** The hook fires for page routes only. Handle `Mochi.api` failures inside the route with `error()` or `apiError()`: ```ts Mochi.api(async () => { const data = await load(); if (!data) return apiError(404, 'Not found'); return json(data); }); ``` If the hook itself throws, Mochi logs the secondary error and renders the error page with the original `status` and `message`. ### API error envelope `Mochi.api` routes return `{ "error": { "message", "status" } }` with the matching status code. Use `MochiHttpError` (typed throw via `error()`) or `apiError()` (typed return) to produce the envelope. See [API routes](/docs/api-routes/). **Use `error(status, message)` to signal status codes.** A bare `throw new Error()` is coerced to a generic 500. Only `error(status, message)` returns the typed error envelope the framework expects. ### Fallback behavior If your `errorPage` throws during render, Mochi returns a plain-text response mentioning both the original error and the secondary render failure. The error page cannot crash the server. --- title: 'Error boundaries' slug: error-boundaries description: 'Hydratable islands are auto-wrapped in svelte:boundary, so one island failure cannot crash the page.' --- ## Error boundaries Mochi auto-wraps every `mochi:hydrate` and `mochi:hydrate:visible` island in ``. A throw inside one island no longer takes down the page render. Mochi marks the failed island with a `` stub, and the rest of the page continues. No opt-in, no configuration. ### What is wrapped - `mochi:hydrate` — wrapped. - `mochi:hydrate:visible` — wrapped. - `mochi:defer` — handled by the server-island endpoint, not by a boundary. - Top-level page render throws — go to the configured `errorPage`. See [Error handling](/docs/error-handling/). ### What gets caught - Synchronous SSR throws inside the island. - Async SSR throws (`await Promise.reject(...)` in a top-level ` ## Utility helpers Functions exported from `mochi-framework` for shaping responses and form-action results. Each helper is documented in depth where it is used. This page is an index. ### Response helpers `json(data, init?)` builds a JSON `Response` with the right `Content-Type`. Use it from `Mochi.api()` handlers and middleware. ```ts import { json } from 'mochi-framework'; return json({ ok: true }, { status: 201 }); ``` `error(status, message?)` throws a `MochiHttpError` that the framework catches and renders as the configured error page (or a JSON envelope from API routes). Omit `message` to get the canonical status text — `Not Found`, `Too Many Requests`, … — with unknown codes falling back to `Error `. See [Error handling](/docs/error-handling/). ```ts import { error } from 'mochi-framework'; if (!user) error(404, 'User not found'); if (!session) error(401); // → "Unauthorized" ``` `apiError(status, message)` returns a JSON error `Response` shaped as `{ error: { message, status } }`. Use it inside `Mochi.api()` for a typed error without unwinding the stack. See [API routes](/docs/api-routes/). ```ts import { apiError } from 'mochi-framework'; return apiError(400, 'Missing id'); ``` ### Form-action helpers Use these as return values from a `Mochi.page` action. See [Defining routes](/docs/defining-routes/) and [Progressive enhancement](/docs/progressively-enhancing-forms-with-enhance/) for the full action lifecycle. `fail(status, data)` re-renders the page with `form = { ok: false, action, status, data }` and the given HTTP status. The payload nests under `form.data`, not on `form` itself. Use it for validation errors. ```ts import { fail } from 'mochi-framework'; if (!username) return fail(400, { error: 'Username required', username }); ``` ```svelte {#if form?.data?.error}

{form.data.error}

{/if} ``` `success(data?)` re-renders the page with `form = { ok: true, action, data }` and HTTP 200. Use it when the action completes and you want to stay on the page. ```ts import { success } from 'mochi-framework'; return success({ message: 'Saved.' }); ``` `redirect(status, location)` issues an HTTP redirect when returned from a form action or a [`serverProps` resolver](/docs/defining-routes/#redirecting-from-serverprops). `status` must be `301`, `302`, `303`, `307`, or `308`. Use 303 for the standard POST/Redirect/GET pattern. ```ts import { redirect } from 'mochi-framework'; return redirect(303, '/dashboard'); ``` ### Sealed tokens `encryptPayload(plaintext, { aad?, compress? })` and `decryptPayload(token, { aad? })` seal a string into an opaque, tamper-proof base64url token (AES-256-SIV keyed from `MOCHI_KEY`) and open it again. `decryptPayload` returns `null` on any tamper or `aad` mismatch. This is the same primitive Mochi uses for server-island props. Use it for short-lived signed values such as form challenges or magic links. ```ts import { encryptPayload, decryptPayload } from 'mochi-framework'; const token = encryptPayload(JSON.stringify({ iat: Date.now() }), { aad: 'my-form' }); const opened = decryptPayload(token, { aad: 'my-form' }); // string | null ``` ### HTML escaping `escapeHtmlAttr(value)` replaces `&`, `"`, `<` and `>` with entities. It is the framework's single attribute encoder, so values round-trip exactly through `getAttribute()` — including payloads that already contain entity sequences like `"`, which a bare `"`-only replace would corrupt. It works for text content too. ```ts import { escapeHtmlAttr } from 'mochi-framework'; const html = `
${escapeHtmlAttr(source)}
`; ``` It has zero imports on purpose, so server modules, SSR, hydrated components and client-bundled web components all share the one implementation. ### Process singletons `pinGlobal(key, factory)` returns one instance per `key` for the life of the process, pinned on `globalThis`. The `factory` runs at most once per key; every later call with the same key returns the same value. ```ts import { SQL } from 'bun'; import { pinGlobal } from 'mochi-framework'; export function getSql() { return pinGlobal('app:sql', () => new SQL(process.env.DATABASE_URL!, { max: 8 })); } ``` Call the accessor wherever you need the value — every call returns the same instance: ```ts import { getSql } from './db/client'; const users = await getSql()`SELECT id, name FROM users`; ``` Reach for it to hold anything that must not be duplicated: a database pool, a connection, a background timer, an in-memory cache. In development this is what keeps such resources from leaking across [route-handler HMR](/docs/development-mode/#route-handler-hmr) — a plain module-scoped `let` is re-created on every reload and its old handle orphaned, but a `pinGlobal` value survives. Namespace your key with an `app:` prefix; the framework pins its own state under `__mochi_*__`. --- title: 'Cache' slug: cache ogTitle: 'Caching with stale-while-revalidate' description: 'Cache server-side data with stale-while-revalidate semantics using MochiCache.' --- ## Cache `MochiCache` caches server-side data — typically slow upstream API calls — with stale-while-revalidate semantics. Construct it 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 ``` **A cache is shared across requests in one process.** So a key like `cart:current` leaks one user's data to another. Prefix per-user keys with the user id, for example `cart:${userId}`, and do the same for any other request-scoped dimension (tenant, locale, role). ### Caching expensive serverProps A page's latency is usually dominated by its data loading, not the render — so `serverProps` resolvers are the highest-leverage place to cache. Construct a `MochiCache` at module scope and wrap the slow call in `fetch()`, keyed per route params: ```ts // file: src/index.ts import { Mochi, MochiCache } from 'mochi-framework'; import { loadPokemon } from './lib/pokemon'; const pokemonCache = new MochiCache({ minTimeToStale: 10_000, // serve fresh for 10s maxTimeToLive: 300_000, // hard expiry at 5min }); await Mochi.serve({ routes: { '/pokemon/:id': Mochi.page('./src/Pokemon.svelte', { serverProps: async (_req, params) => ({ pokemon: await pokemonCache.fetch(`pokemon:${params.id}`, () => loadPokemon(params.id)), }), }), }, }); ``` The first request per key pays the load; later requests follow the fresh / stale / expired lifecycle below. Everything that shapes the result must be in the key — route params as above, plus any cookie- or locals-derived dimension per the warning above. ### Behavior - **Fresh** (within `minTimeToStale`): cached value returned, no fetch. - **Stale** (between `minTimeToStale` and `maxTimeToLive`): cached value returned at once, 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 }>` | | `peek(key)` | `Promise<{ value, status } \| null>` | | `set(key, value)` | `Promise` | | `markStale(key)` | `Promise` | | `delete(key)` | `Promise` | | `clearItems()` | `Promise` | | `whenIdle()` | `Promise` | `peek(key)` reports a key's `status` and value **without** running `fn`, revalidating, or emitting `cache:read` — a pure probe that returns `null` on a miss. `markStale(key)` backdates an entry so its next read serves stale-while-revalidate. It is a no-op on a missing or already-stale key, and it never freshens or un-expires one. Both run through the `storage` interface, so they apply to any backend. `set(key, value)` writes a value directly, stamped fresh. Prefer `set` over `delete(key)` then `fetch`: that sequence leaves the key absent, so concurrent readers each start their own recompute. `status` is `'fresh' | 'stale' | 'expired' | 'miss'`. #### Waiting for background revalidations A stale read returns at once and refreshes in the background, so the new value is not readable when `fetch` resolves. `whenIdle()` waits for those background runs to finish — including the storage write, not just the upstream call — which is what you want before shutting down, or in a test that asserts on the refreshed value. ```ts const stale = await cache.fetchWithStatus('users', loadUsers); // 'stale', old value await cache.whenIdle(); const fresh = await cache.fetchWithStatus('users', loadUsers); // 'fresh', new value ``` `whenIdle()` waits for whatever is in flight, so on a busy cache it keeps waiting as new revalidations start. Treat it as a shutdown or test primitive — don't await it on a request path. A run that throws settles it like any other, so a failing upstream can't leave it hanging. `ImageCache` exposes the same method for its own regenerations. ### Options | Option | Default | | ---------------- | ----------------- | | `minTimeToStale` | `5_000` (5s) | | `maxTimeToLive` | `600_000` (10min) | | `storage` | `MemoryStorage` | | `serialize` | none (`v => v`) | | `deserialize` | none (`v => v`) | Two storage backends ship with the framework: `MemoryStorage` (the default) and `FileStorage`. Built-in SQLite and Postgres backends are planned; until then any other backend — SQLite, Postgres, Redis — needs a `storage` you write yourself, implementing `getItem` / `setItem` / `removeItem` / `clear`. Those methods may be synchronous or `async`. The cache awaits every call. When a backend needs a string or buffer, supply `serialize` / `deserialize` — for example `serialize: JSON.stringify, deserialize: JSON.parse`. The default `MemoryStorage` accepts `{ maxAge, purgeInterval }` for age-based eviction. With no options it never evicts. See [Persistence](/docs/persistence/) for how cache storage compares to the other stateful subsystems. ### File-based storage `FileStorage` persists the cache to disk, so it survives restarts — the only built-in persistent backend (see [Persistence](/docs/persistence/)). It needs no `serialize` / `deserialize`: ```ts import { MochiCache, FileStorage } from 'mochi-framework'; export const pokemonCache = new MochiCache({ minTimeToStale: 10_000, maxTimeToLive: 300_000, storage: new FileStorage({ directory: './.cache/pokemon', maxAge: 300_000, // must be >= maxTimeToLive }), }); ``` A background sweep deletes expired files on an interval. `purgeOnInit` empties the directory on startup. Binary fields (`Uint8Array` / `Buffer`) anywhere in a value round-trip transparently. By default Mochi inlines them as base64 in the JSON and returns them as `Uint8Array`. When values carry large binaries, set `offloadBinary: true`: Mochi writes each binary to its own file in a `/` folder and puts a pointer in the JSON instead of base64. An offloaded field reads back as a lazy blob reference, so a metadata read never loads the bytes. Resolve one with `readBlobRef(ref)`, and narrow a value with `isBlobRef(value)`. Deleting a key also removes its blob folder. Pointers already on disk always decode, so the flag never orphans existing entries. The built-in [image cache](/docs/images/) enables offloading internally. | Option | Default | | | --------------- | ----------------- | -------------------------------------------------------------------------- | | `directory` | _(required)_ | Where cache files are written; created if missing. | | `purgeOnInit` | `false` | Delete the directory's contents when the adapter is constructed. | | `purgeInterval` | `60_000` (1min) | Background sweep interval in ms. `<= 0` disables the sweeper. | | `maxAge` | `600_000` (10min) | Files older than this are deleted by the sweep. | | `offloadBinary` | `false` | Offload binary fields to per-key blob files, read back as lazy `BlobRef`s. | Only one background sweeper runs per cache directory per process. A new `FileStorage` on the same directory transfers the sweep to the newest instance, and with it that instance's `maxAge`, so a dev-server reload that re-runs your module never stacks up duplicate sweepers. Ownership never moves back: `dispose()` on the newest instance ends the sweep for that directory, even if an older instance is still in use. **Keep `maxAge` at or above `maxTimeToLive`.** The sweep deletes files past `maxAge`. Set it lower and the sweeper removes entries the cache still wants to serve stale, turning a fast stale read into a blocking recompute. Values must be JSON-serializable (no `Date`, `Map`, `BigInt`, or `undefined` round-trip). **In-flight de-duplication is per-server.** Concurrent calls for the same key on one instance share a single `fn` invocation. With multiple instances behind a shared backend, each instance de-duplicates only its own requests, so on a cold key every instance may run `fn` once and race to write the same entry. The shared store keeps results consistent. If a `storage` call throws, the cache degrades instead of failing the request: a read error recomputes via `fn` (reported as a `miss`), a write error returns the freshly computed value uncached, and a `delete` error is re-thrown to the caller. Every case emits a `cache:error` event. ### Subscribing to cache events `MochiCache` emits these events on `mochiEvents`: | Event | Payload | When | | ------------------------- | ---------------------------------------- | ------------------------------------------------------------------ | | `cache:read` | `{ key, status }` | Every cache lookup. | | `cache:revalidate` | `{ key }` | A background refetch starts (stale read). | | `cache:delete` | `{ key }` | A key was removed via `delete(key)`. | | `cache:sweep` | `{ removed, durationMs }` | A `FileStorage` background sweep deleted expired files. | | `cache:pressure` | `{ level, removed, caches, durationMs }` | The OS reported low memory and Mochi drained its in-memory caches. | | `cache:revalidate:failed` | `{ key, error }` | A background refetch threw; the stale value is still served. | | `cache:error` | `{ key, operation, error }` | A `storage` `get` / `set` / `remove` call threw. | ### Memory pressure When the operating system runs low on memory, Bun raises `process.on("memoryPressure")` and Mochi drains every in-memory cache before the kernel starts killing processes. `'critical'` (all platforms) clears them outright; `'warning'` (macOS only) drops just the aged-out entries, so a store without `maxAge` keeps everything. Each response emits one `cache:pressure` event and one `consoleLogger()` warning. Only `MemoryStorage` participates — it is the backend that holds bytes in RAM. `FileStorage` is disk-backed, so dropping it would not help. Turn the whole thing off with `Mochi.serve({ memoryPressure: false })`. Reclamation runs in production only. In development (`development: true`) it is always disabled — the compile-heavy boot hair-triggers the OS signal (Linux reports only `'critical'`), so the reclaim would be a spurious no-op. The raw signal is also broadcast as a `memory:pressure` event (payload `{ level }`) the moment it arrives, before the cache drain. Subscribe to reclaim resources Mochi doesn't own — idle connection pools, worker queues, your own maps: ```ts import { mochiEvents } from 'mochi-framework'; mochiEvents.on('memory:pressure', ({ level }) => { if (level === 'critical') pool.drainIdle(); }); ``` ```ts // file: src/index.ts await Mochi.serve({ memoryPressure: false, routes }); ``` `consoleLogger()` surfaces `cache:revalidate:failed` and `cache:error` as warnings. Use `mochiEvents.setHandler` to attach a custom subscriber — it replaces a prior handler under the same name, so dev re-imports do not pile up listeners: ```ts import { mochiEvents } from 'mochi-framework'; mochiEvents.setHandler('metrics:cache-read', 'cache:read', ({ key, status }) => { metrics.increment(`cache.${status}`, { key }); }); ``` `consoleLogger()` prints `cache:revalidate` lines by default. Pass `{ cache: 'verbose' }` to print every read, or `{ cache: false }` to silence cache logging. ### Per-request memoization `MochiCache` is process-wide and outlives the request. To collapse repeated work **within** a single render, use the [request cache](/docs/request-cache/) instead. It needs no TTL because entries die with the request. ### Server-only `MochiCache` lives on the server. Importing it into a hydratable island throws. Construct cache instances in `.ts` modules or page-route scripts, never inside a `mochi:hydrate` component. --- title: 'Logging' slug: logging ogTitle: 'Isomorphic, level-gated logging' description: 'An isomorphic, level-gated logger that works in server, SSR, and client contexts.' --- ## Logging Mochi exposes one isomorphic `logger` with five methods. The same import works on the server, in SSR, in hydrated Svelte components, and in vanilla web components. A configurable level set on `Mochi.serve()` gates it. ```ts import { logger } from 'mochi-framework'; logger.error('boom'); logger.warn('careful'); logger.info('starting up'); logger.log('verbose detail'); logger.debug('asset request'); ``` Methods map to `console.error` / `console.warn` / `console.info` / `console.log` / `console.debug`. Each line is prefixed with a coloured `[mochi]`. A call below the configured level is a no-op with negligible overhead. ### Log level ```ts import { Mochi } from 'mochi-framework'; await Mochi.serve({ port: 3333, routes, logger: { level: 'warn' }, }); ``` `level` accepts `'silent' | 'error' | 'warn' | 'info' | 'log' | 'debug'`. A method runs when its severity is at or above the active level. So `'warn'` lets `error` and `warn` through and suppresses `info`, `log`, and `debug`. | Level | What you see | When to use | | ---------- | ---------------------------------------------------------------------------------- | ----------------------------------------------- | | `'silent'` | Nothing — no boot line, no requests, no errors | Tests; CLI scripts that want no noise | | `'debug'` | Everything `'log'` shows, plus per-asset request lines and fallbacks | Investigating asset fetches or unmatched routes | | `'log'` | Adds chatty client-side hydration traces and other verbose detail | Debugging hydration / island lifecycle | | `'info'` | Boot line, page/api/file requests, file-change notifications, plus warnings/errors | Default in development | | `'warn'` | Slow requests, 5xx responses, queue lifecycle, deprecations, recoverable problems | Default in production | | `'error'` | Only handler failures and unhandled exceptions | Production with a separate alerting pipeline | Which severity each event lands on is a framework default. Remap them per app with the [`consoleLogger:level` filter](/docs/extensions/). If `level` is omitted, Mochi picks the default from the [mode](/docs/development-mode/): `'info'` in development, `'warn'` in production. The level applies on both server and client. The server sends its configured level to the browser, so client-side `logger` calls honour it too. Reload the page after changing the config to pick up a new level on the client. `level: 'silent'` really means silent, including the `BOOT` and `STOP` lines. To deliver lifecycle events to your own subscribers with no console output, set `logger: { enabled: false }` instead. That keeps the event bus alive and disables the formatter. ### Setting the level at runtime ```ts import { setLogLevel, getLogLevel } from 'mochi-framework'; setLogLevel('error'); getLogLevel(); // 'error' ``` `setLogLevel` is for niche cases such as toggling verbosity from a feature flag. The serve-time config is the right place for normal use. `setLogLevel` updates only the bundle it is called from. The server, the main client bundle, and each island bundle carry their own copy of the level. They are seeded consistently at startup. For a global change, update `Mochi.serve({ logger: { level } })` and reload. ### Relationship to `mochiEvents` The event bus (`mochiEvents`) carries structured payloads to any subscriber you wire up, regardless of console output. The built-in `consoleLogger()` — the thing that prints request lines like `GET /foo 200 12ms` — is one consumer that subscribes to those events and calls `logger.info` / `logger.warn` per event. Plug Sentry, OpenTelemetry, or your own pipeline directly into `mochiEvents`. Use `logger` for ad-hoc messages. --- title: 'Request cache' slug: request-cache description: 'Memoize server-side work for the duration of a single request with requestCache and requestMemo.' --- ## Request cache The request cache memoizes work for the duration of one HTTP request. Entries die with the request, so a page that renders the same lookup in ten components pays for it once, and the next request sees fresh data. ```ts import { requestCache } from 'mochi-framework'; const user = await requestCache(`user:${id}`, () => db.user(id)); ``` Every value the callback reads must appear in the key. The cache never inspects the function, so a key that omits `id` collides silently. ### requestMemo Wrap a function once at module scope. Every call site is then memoized by its arguments: ```ts import { requestMemo } from 'mochi-framework'; export const getUser = requestMemo((id: string) => db.user(id)); ``` ```svelte ``` The wrapper is the shared identity. Two separate `requestMemo()` calls over the same function get separate entries. Export the wrapped function so every importer shares it, or pass `{ namespace }` to share entries between wrappers. Arguments are keyed by a type-tagged serialization (`1` and `'1'` never collide, and objects go through `JSON.stringify`). For arguments that cannot serialize, pass your own `key`: ```ts const getProfile = requestMemo((user: User) => db.profile(user.id), { key: (user) => user.id }); ``` ### Async Both forms store the in-flight promise on the first call, so concurrent callers share one execution: ```ts // One fetch, three awaits. const [a, b, c] = await Promise.all([getUser('42'), getUser('42'), getUser('42')]); ``` A rejected promise evicts its entry, so a failure is never cached. The next call retries. ### The store `getRequestCache()` returns the underlying store for imperative access: ```ts import { getRequestCache } from 'mochi-framework'; const cache = getRequestCache(); cache.set('tenant', tenant); cache.get('tenant'); cache.delete('tenant'); cache.stats(); // { hits, misses } — also shown in the debug bar's Cache panel ``` ### Outside a request Called outside a request handler — a startup script, a background job, a detached email render — the callback runs uncached, with a one-time warning in development. Nothing throws, so helpers built on the request cache stay usable everywhere. For a `requestMemo` wrapper expected to run outside a request, pass `{ quiet: true }` to suppress that warning: ```ts export const getUser = requestMemo((id: string) => db.user(id), { quiet: true }); ``` ### On the client These are server-only helpers. In the browser bundle they resolve to no-op stubs instead of throwing: `requestCache(key, fn)` runs `fn()` uncached, `requestMemo(fn)` returns `fn` unwrapped, and `getRequestCache()` hands back a fresh throwaway store per call. **The request cache is a server-side convenience API.** Inside a hydrated component the calls run without the server's cached values — `requestCache(key, () => db.user(id))` runs on the server during SSR and again on the client during hydration, where `db` might not exist. To reuse a server-computed value on hydration, wrap it in Svelte's [`hydratable(key, fn)`](/docs/hydratable/) instead, or pass it as `serverProps`. **Not a replacement for `MochiCache`.** The request cache has no TTL, no storage backend, and no eviction — the request boundary is the TTL. Use [`MochiCache`](/docs/cache/) for anything that should survive a request. Use the request cache to stop repeating work within one. ### In the debug bar In development, the debug bar's **Cache** panel has a **Request cache** section reporting hits, misses, hit rate, and surviving entries for the render that produced the page. --- title: 'Events' slug: events ogTitle: 'The framework lifecycle event bus' description: 'Subscribe to framework lifecycle events like requests, WebSocket activity, and builds via a mitt emitter.' --- ## Events Mochi exposes a process-wide [`mitt`](https://www.npmjs.com/package/mitt) emitter named `mochiEvents`. Subscribe from application code to feed metrics, audit logs, custom log destinations, or anything else that needs a structured view of server activity. Event names use a `namespace:action` convention. Every key is in the typed `MochiEventMap`, so handlers receive a precise payload type without casts. ### Event index - [`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 - [`queue:added`](#queueadded), [`queue:active`](#queueactive), [`queue:completed`](#queuecompleted), [`queue:failed`](#queuefailed), [`queue:error`](#queueerror) — [background job](/docs/queues/) lifecycle - `cron:scheduled` — a [scheduled job](/docs/scheduled-jobs/) was registered (its runs surface through `queue:*` on the job name) - [`email:sent`](#emailsent), [`email:error`](#emailerror) — [transactional email](/docs/email/) delivery - [`server:start`](#serverstart), [`server:stop`](#serverstop) — server lifecycle - [`warmup:start`](#warmupstart), [`warmup:complete`](#warmupcomplete) — route warmup batch (only with `warmup: true`) - [`error`](#error) — a page/api/action handler threw - [`action:invoke`](#actioninvoke), [`action:complete`](#actioncomplete) — form action lifecycle - [`compile:start`](#compilestart), [`compile:complete`](#compilecomplete), [`compile:error`](#compileerror) — Svelte SSR build - [`recompile:start`](#recompilestart), [`recompile:complete`](#recompilecomplete) — dev rebuild cycle - [`recompile:module-churn`](#recompilemodule-churn) — entry re-imported many times in a dev session (resource-leak warning) - [`client-bundle:complete`](#client-bundlecomplete) — hydratable client bundle finished - [`island:error`](#islanderror) — an island errored - [`captcha:verify`](#captchaverify) — a `` submission was verified or rejected - [`file:change`](#filechange) — dev-only file watcher - [`image:store`](#imagestore), [`image:delete`](#imagedelete) — [``](/docs/images/) cache activity - `image:cache-sweep` — aggregate counts per janitor sweep (see [Images](/docs/images/)) - `cache:read`, `cache:revalidate` — see [Cache events](/docs/cache/#subscribing-to-cache-events) - `memory:pressure` — the OS reported low memory; fires before the cache drain so other subsystems can reclaim too (see [Cache](/docs/cache/#memory-pressure)) - `cache:pressure` — the OS reported low memory and in-memory caches were drained (see [Cache](/docs/cache/#memory-pressure)) ### Subscribing ```ts import { mochiEvents } from 'mochi-framework'; mochiEvents.on('request', ({ method, path, status, duration }) => { metrics.timing('http.request', duration, { method, path, status }); }); ``` **Keep async work out of handlers.** Handlers run synchronously and block the emission chain. Offload metrics, logging, and I/O to a fire-and-forget async task so downstream handlers are not delayed. ### `mochiEvents.setHandler` Use `setHandler(name, type, handler)` to register a named subscriber. It replaces any prior handler stored under the same `name`, so dev re-imports never pile up duplicate listeners. ```ts mochiEvents.setHandler('metrics:request', 'request', ({ status, duration }) => { metrics.timing('http.request', duration, { status }); }); ``` Namespace `name` (`metrics:request`, not `request`) so unrelated subsystems do not evict each other. ### `hasSubscribers` Use `hasSubscribers(name)` to skip payload construction when nobody is listening: ```ts import { hasSubscribers, mochiEvents } from 'mochi-framework'; if (hasSubscribers('compile:error')) { mochiEvents.emit('compile:error', expensivePayload()); } ``` ### `requestId` correlation Every HTTP request carries a stable `requestId` on `request`, `error`, `action:invoke`, and `action:complete`. Use it to stitch a 500 trace together. The same id is on the request context. To honour an upstream id from a trusted reverse proxy, set `proxy.requestIdHeader` on `Mochi.serve()`. **Only set `proxy.requestIdHeader` for traffic you fully control.** Clients can spoof headers. If you trust untrusted traffic, attacker-controlled ids correlate unrelated requests together in logs. ### Event reference Each event ships a typed payload matching `MochiEventMap` in `events.ts`. #### `request` Fires once per HTTP response, including CSRF rejects. Covers `Mochi.page` and `Mochi.api` routes. | Field | Type | Notes | | ----------- | ---------------------- | ---------------------------------------------------------- | | `requestId` | `string` | correlation id | | `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 | | `warmup` | `boolean \| undefined` | `true` when issued by [route warmup](/docs/serve-options/) | #### `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 | #### `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 | #### `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) | #### `sse:open` Fires when an SSE stream starts. | Field | Type | Notes | | ------ | -------- | ------------ | | `path` | `string` | URL pathname | #### `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()` | #### `sse:close` Fires when the SSE stream closes. A client disconnect or an explicit close both count. | Field | Type | Notes | | ---------- | -------- | ---------------------- | | `path` | `string` | URL pathname | | `duration` | `number` | ms the stream was open | #### `queue:added` Fires after `queue.add()` / `queue.addBulk()` enqueues a job. See [Queues](/docs/queues/). | Field | Type | Notes | | ------- | ---------------------- | --------------------------------------- | | `queue` | `string` | queue name | | `jobId` | `string` | generated job id | | `bulk` | `boolean \| undefined` | `true` when the add came from `addBulk` | #### `queue:addedBulk` Fires once per `addBulk()` call that inserted at least one job, alongside the per-job `queue:added` events. The [console logger](/docs/logging/) prints this summary instead of the per-job lines. | Field | Type | Notes | | -------- | ---------- | -------------------------------------------------- | | `queue` | `string` | queue name | | `count` | `number` | jobs actually inserted (duplicate ids are skipped) | | `jobIds` | `string[]` | ids of the inserted jobs | #### `queue:active` Fires when a worker starts a job. | Field | Type | Notes | | --------- | -------- | --------------------------------------- | | `queue` | `string` | queue name | | `jobId` | `string` | job id | | `attempt` | `number` | 1-based attempt number (1 on first run) | #### `queue:completed` Fires when a job's processor returns successfully. | Field | Type | Notes | | ---------- | -------- | -------------------------------- | | `queue` | `string` | queue name | | `jobId` | `string` | job id | | `attempt` | `number` | attempt that succeeded | | `duration` | `number` | ms the processor ran the attempt | #### `queue:failed` Fires when a job's processor throws. One emission per failed attempt. | Field | Type | Notes | | ---------- | -------- | ------------------------------ | | `queue` | `string` | queue name | | `jobId` | `string` | job id | | `attempt` | `number` | attempt that failed | | `duration` | `number` | processing ms before the throw | | `error` | `string` | thrown error message | #### `queue:error` Fires for a queue-runtime error not tied to one job, for example a poll failure. | Field | Type | Notes | | ------- | --------- | ---------------------------------------------- | | `queue` | `string?` | absent for instance-level errors with no queue | | `error` | `string` | error message | #### `email:sent` Fires after `Mochi.email()` hands a message to its transport. The [`email:message` filter](/docs/extensions/#emailmessage) can veto it first. See [Email](/docs/email/). | Field | Type | Notes | | ----------- | ------------------------------------------------------ | ------------------------------------------------------------------- | | `to` | `string[]` | recipient addresses, as actually sent | | `subject` | `string` | message subject | | `transport` | `'smtp' \| 'custom' \| 'log' \| 'dev' \| 'suppressed'` | which transport delivered it; `'suppressed'` when the filter vetoed | | `messageId` | `string \| undefined` | provider/SMTP id, when the transport returns one | | `duration` | `number` | send wall-clock in ms | #### `email:error` Fires when a transport throws while sending. `Mochi.email()` re-throws after emitting. | Field | Type | Notes | | ----------- | -------------------------------------- | ---------------------------------------- | | `to` | `string[]` | recipient addresses | | `cc` | `string[] \| undefined` | cc recipients, when the message had any | | `bcc` | `string[] \| undefined` | bcc recipients, when the message had any | | `subject` | `string` | message subject | | `transport` | `'smtp' \| 'custom' \| 'log' \| 'dev'` | transport that failed | | `error` | `string` | error message | #### `server:start` Fires once after `Bun.serve()` binds the listening socket. | Field | Type | Notes | | ------------- | -------------------------------------------------------- | --------------------------------- | | `port` | `number \| undefined` | bound TCP port (absent over Unix) | | `hostname` | `string \| undefined` | bound hostname if any | | `development` | `boolean` | dev or prod mode | | `routes` | `{ page: number; api: number; ws: number; sse: number }` | route counts by kind | #### `server:stop` Fires when the server shuts down — on `SIGTERM` / `SIGINT`, or a programmatic [`Mochi.stop()`](/docs/queues/#mochistop) — after the `mochi:shutdown` hook runs. | Field | Type | Notes | | -------- | ------------------------------------ | ---------------------------------------------- | | `reason` | `'signal' \| 'stop'` | signal, or programmatic `Mochi.stop()` | | `signal` | `'SIGTERM' \| 'SIGINT' \| undefined` | the signal received; absent for `Mochi.stop()` | #### `warmup:start` Fires once when the [route warmup](/docs/serve-options/#route-warmup) batch begins. Only emitted with `warmup: true`. | Field | Type | Notes | | ------------ | -------- | ------------------------------------- | | `routeCount` | `number` | static page routes about to be warmed | #### `warmup:complete` Fires once after the [route warmup](/docs/serve-options/#route-warmup) batch finishes. Only emitted with `warmup: true`. | Field | Type | Notes | | ------------ | -------- | ---------------------------------------- | | `routeCount` | `number` | static page routes warmed | | `errorCount` | `number` | warmup invocations that threw or 5xx'd | | `durationMs` | `number` | wall-clock ms for the whole warmup batch | #### `error` Fires when a page, API, or form action handler throws and the framework returns an error response. | Field | Type | Notes | | ------------ | ----------------------------- | -------------------------------------- | | `requestId` | `string` | correlates with the matching `request` | | `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, dev only | | `actionName` | `string \| undefined` | 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` through `requestId`. | Field | Type | Notes | | ------------ | -------- | ------------------------------------ | | `requestId` | `string` | correlates with `action:complete` | | `path` | `string` | URL pathname + search | | `actionName` | `string` | action name (`'default'` if unnamed) | #### `action:complete` Fires after a form action returns or throws. One emission per invocation, whatever the 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` | #### `compile:start` Fires before each Svelte SSR compile. A cache hit skips it. | Field | Type | Notes | | ------ | -------- | --------------------------- | | `path` | `string` | absolute path of the source | #### `compile:complete` Fires after a successful compile. | 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 | | `durationMs` | `number` | wall-clock time spent inside `compile()` | #### `compile:error` Fires when `Bun.build` rejects a Svelte source. The framework still throws after emitting. The event exists for tooling that wants the structured logs. | Field | Type | Notes | | --------- | --------------------------------------------------------------------------- | -------------------------------- | | `path` | `string` | source that failed | | `message` | `string` | top-line error message | | `logs` | `Array<{ file?: string; line?: number; column?: number; message: string }>` | per-message diagnostics from Bun | #### `recompile:start` Fires from the dev watcher before a rebuild cycle begins. Production builds never emit. It wraps either a full SSR rebuild (`trigger: 'file' | 'svelte-config'`) or the CSS-only fast path (`trigger: 'css'`). | Field | Type | Notes | | ----------- | ------------------------------------ | ------------------------------------------------ | | `trigger` | `'file' \| 'css' \| 'svelte-config'` | which watcher path fired | | `path` | `string` | file whose change triggered the rebuild | | `pageCount` | `number` | pages about to be rebuilt (`0` for the CSS path) | #### `recompile:complete` Fires after the matching `recompile:start`, once the rebuild finishes and clients are told to reload. `clientBundleCount` counts `buildClientBundle()` calls inside the cycle. For a typical `'file'` trigger it must be `1`, or `0` when no hydratables are registered. A value above `1` means the registry's bundle deferral stopped working and you regressed to per-page bundling. | Field | Type | Notes | | ------------------- | ------------------------------------ | ---------------------------------------------- | | `trigger` | `'file' \| 'css' \| 'svelte-config'` | matches `recompile:start` | | `path` | `string` | matches `recompile:start` | | `pageCount` | `number` | pages that were rebuilt | | `clientBundleCount` | `number` | `buildClientBundle()` invocations during cycle | | `durationMs` | `number` | wall-clock ms for the whole cycle | #### `recompile:module-churn` Fires once per dev session, when the entry has been re-imported `reloadCount` times (default 10). Each reload re-evaluates the whole first-party module graph, so a module-scoped resource is re-created and the old one orphaned — see [route-handler HMR](/docs/development-mode/#route-handler-hmr). `consoleLogger()` renders it as a `warn`-level `HMR` line; suppress that line with a [`consoleLogger:line`](/docs/extensions/#consoleloggerline) filter matching `source.name === 'recompile:module-churn'`. | Field | Type | Notes | | ------------- | -------- | ------------------------------------------- | | `reloadCount` | `number` | entry re-imports so far this session (≥ 10) | ```ts import { mochiEvents } from 'mochi-framework'; mochiEvents.on('recompile:module-churn', ({ reloadCount }) => { console.warn(`entry re-imported ${reloadCount}× — hold resources with pinGlobal()`); }); ``` #### `client-bundle:complete` Fires whenever the registry rebuilds the hydratable client bundle. Production builds emit once at startup. Dev mode emits during `recompileAll()` and on lazy first-hit compiles for server islands. | Field | Type | Notes | | ------------- | -------- | -------------------------------------------------------- | | `entryCount` | `number` | entrypoints fed to Bun.build (bootstrap + per-component) | | `outputBytes` | `number` | sum of all output sizes (JS + CSS) from the bundle | | `durationMs` | `number` | wall-clock ms inside `buildClientBundle()` | #### `captcha:verify` Fires when [`verifyCaptcha()`](/docs/captcha/) finishes. The client gets one generic message, but this event carries the real cause. | Field | Type | Notes | | -------- | --------------------- | ------------------------------------------------------------------------- | | `ok` | `boolean` | whether verification passed | | `reason` | `MochiCaptchaReason` | `'ok' \| 'malformed' \| 'expired' \| 'too-fast' \| 'bad-pow' \| 'replay'` | | `bits` | `number \| undefined` | difficulty sealed in the token | | `ageMs` | `number \| undefined` | token age at verification | #### `island:error` Fires when an island fails: a server-island render, a hydratable SSR render, or client-side hydration. The framework still ships an error placeholder. See [Error boundaries](/docs/error-boundaries/#islanderror-event). | Field | Type | Notes | | --------------- | ---------------------------------------------- | ------------------------------------------------- | | `componentName` | `string` | island component identifier | | `islandId` | `string \| undefined` | envelope id; set for `'server'`, else `undefined` | | `kind` | `'hydratable' \| 'server' \| 'client-hydrate'` | which lifecycle stage failed | | `message` | `string` | error message | | `stack` | `string \| undefined` | stack trace, dev only | #### `file:change` Fires from the dev file watcher (chokidar). Production builds do not 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'` | #### `image:store` Fires when the [``](/docs/images/) cache commits a file to disk: a downloaded full-size `original`, a resized `variant`, or a ThumbHash blur `placeholder`. Emitted once per regeneration, because concurrent misses coalesce. Use it to mirror cache writes to durable storage such as S3. | Field | Type | Notes | | ------------- | ------------------------------------------ | ------------------------------------------------------- | | `kind` | `'original' \| 'variant' \| 'placeholder'` | which entry type was written | | `src` | `string` | the image source (URL/key) this entry derives from | | `path` | `string` | absolute path of the file just committed on disk | | `id` | `string` | `variantId` for `variant`; `originalId(src)` otherwise | | `size` | `number` | bytes written | | `contentType` | `string` | authoritative content type; `''` for `placeholder` | | `width` | `number` | pixel width; `0` for `original` and `placeholder` | | `height` | `number` | pixel height; `0` for `original` and `placeholder` | | `format` | `string` | encoded format such as `'webp'`; `''` for the two above | ```ts import { readFileSync } from 'node:fs'; import { mochiEvents } from 'mochi-framework'; mochiEvents.on('image:store', ({ kind, src, path, contentType }) => { const body = readFileSync(path); // sync: the file is guaranteed present now void s3.putObject({ Bucket, Key: `img/${kind}/${src}`, Body: body, ContentType: contentType }); }); ``` Read the file **synchronously at the top of the handler** — it provably exists at emit time — then offload the upload to a fire-and-forget task. A lazy `await readFile(path)` inside a slow handler could race the janitor sweep and miss the file. #### `image:delete` Fires when the `` cache removes a file from disk. The janitor sweep evicts it, a newer generation supersedes it, or you invalidate it explicitly. Pair it with `image:store` to keep an S3 mirror in sync. A bulk `invalidateSrc()` only emits per-file deletes while a subscriber is registered. | Field | Type | Notes | | -------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `kind` | `'original' \| 'variant' \| 'placeholder'` | which entry type was removed | | `src` | `string` | the image source this entry derived from | | `path` | `string` | absolute path of the removed file | | `id` | `string` | same id scheme as `image:store` | | `size` | `number` | bytes reclaimed (`0` if the file was already gone) | | `reason` | `'evicted' \| 'superseded' \| 'invalidated'` | `evicted` = past its window (sweep); `superseded` = newer generation; `invalidated` = explicit invalidate call | ```ts mochiEvents.on('image:delete', ({ kind, src, path }) => { void s3.deleteObject({ Bucket, Key: `img/${kind}/${src}` }); }); ``` ### Custom events `mochiEvents` is a plain mitt emitter. `emit` your own keys on it for quick experiments. Custom keys are absent from `MochiEventMap`, so handlers and emit sites lose typing. ### Built-in subscribers `consoleLogger()` already prints `request`, `ws:*`, `sse:*`, `server:*`, `error`, and `cache:revalidate` lines. Pass `{ cache: 'verbose' }` to also print every `cache:read`, or `{ cache: false }` to silence cache logging. --- title: 'Images' slug: images ogTitle: 'On-the-fly image transforms' description: 'On-the-fly image transforms on Bun.Image via named sizes, with encrypted URLs and a stale-while-revalidate disk cache.' --- ## Images Mochi transforms images on the fly with [`Bun.Image`](https://bun.com/docs/runtime/image) and serves them from an encrypted, stale-while-revalidate disk cache. Declare transforms once as **named sizes** in `Mochi.serve()`, then reference them by name. `` and `getImageUrl()` mint a signed URL only. The fetch, decode, and transform happen lazily in the `/_mochi/image` endpoint on the browser's request, so **SSR never blocks on image work**. Every URL payload is encrypted with a key derived from your `MOCHI_KEY`, so the source URL stays hidden and an attacker cannot request arbitrary sources or transforms. ### Declare sizes Define transforms under `Mochi.serve({ image: { sizes } })`. Each size is a named recipe: ```ts await Mochi.serve({ image: { sizes: { thumbnail: { width: 200, height: 200, fit: 'inside', format: 'webp', quality: 80 }, avatar: { width: 96, height: 96, fit: 'fill' }, grayscale: { width: 600, modulate: { saturation: 0 }, format: 'jpeg', quality: 85 }, }, }, routes, }); ``` Transforms apply in a fixed order: resize → rotate → flip → flop → modulate → format-encode. Mochi validates sizes at startup. Redefining a size re-renders every URL that uses it, since a config hash is folded into the cache key and `ETag`. | Size field | Default | Notes | | -------------------- | ------------------- | ------------------------------------------------------------------ | | `width` / `height` | — | Target size; height-only derives width by ratio | | `fit` | `'inside'` | `inside` keeps aspect and fits within W×H; `fill` stretches to W×H | | `withoutEnlargement` | `false` | Never upscale beyond the source's intrinsic size | | `rotate` | none | Degrees clockwise | | `flip` / `flop` | `false` | Mirror vertically / horizontally | | `modulate` | none | `{ brightness?, saturation?, hue?, lightness? }` (`1` = unchanged) | | `format` | `defaultFormat` | `webp` \| `jpeg` \| `png` \| `avif` | | `quality` | `defaultQuality` | 1–100 (ignored for `png`) | | `autoOrient` | global `autoOrient` | Apply EXIF orientation | > `Bun.Image` supports `fit: 'inside'` and `fit: 'fill'` only. For an exact square from a non-square source use `fill` (which stretches). Otherwise `inside` keeps the aspect ratio. ### Component Import `Image` and reference a size by name. It renders one `` with an encrypted `src` and no client JavaScript. The `` `width`/`height` default to the size's declared dimensions. ```svelte A photo ``` Add `placeholder` to render a [ThumbHash](https://evanw.github.io/thumbhash/) blur behind the image. It is set as the `` `background-image` (no client JavaScript), and the loaded image paints over it with a CSS blur-up. The blur is computed in the background on first use, so it appears from the second render onward. ```svelte A photo ```
Side by side: a soft colour-blurred rectangle on the left, and on the right the photo it resolves to — a mochi on a wooden board beside a pink lily
The ThumbHash blur (left) and the image it resolves to (right).
| Prop | Default | Notes | | ---------------------- | ----------- | -------------------------------------------------------------------------- | | `src` | — | http/https URL, or a [local image import](#local-image-imports) (required) | | `size` | — | Named size; omitted → the full-size original | | `alt` | `''` | Always set this | | `placeholder` | `false` | Background-warmed ThumbHash blur-up; pure SSR, no client JavaScript | | `width` / `height` | size's dims | `` attribute override | | `loading` / `decoding` | lazy/async | Passed through to `` | A bare `` with no `size` serves the full-size original. An unknown size name degrades to the original and logs a one-time server warning. `` works inside `mochi:hydrate*` islands at any depth — it detects the hydrating subtree with [`isHydratable()`](/docs/selective-hydration/#ishydratable). Minting needs the server secret, so inside an island the minted URL is serialized into the page via Svelte's `hydratable` and reused during hydration. If a client-side re-render changes the image props, there is no snapshot to reuse and the `` degrades to the raw `src` URL. Hydrated-island props ship in plain text in the page HTML, so a `src` you pass into a `mochi:hydrate` island is visible to the client, even though the minted image URL stays encrypted. If your origin must stay secret, keep `` in server-rendered markup or a server island (`mochi:defer`), whose props are encrypted. ### Local image imports Import a local image Vite-style and get an object with its served URL and intrinsic metadata: ```svelte A resized local photo ``` Supported formats: png, jpg, jpeg, webp, avif, gif. Put SVGs in your `public/` directory and reference them with a plain ``. Mochi copies the file to a content-hashed URL (`/_mochi/asset/-.`) and serves it from disk with a long-lived immutable cache in production. Transforms read the file from disk, so `` and `placeholder` work without an origin round-trip. Emitted copies live under `/assets/` and [relocate with the build](/docs/production-builds/#relocatable-builds). The `{ src, width, height, format }` shape is available as the exported `ImportedImage` type. Ambient module types come free through `mochi-framework/ambient`. A bare `` with no `size` renders the original at its intrinsic dimensions straight from that static URL. It never calls the image endpoint. Two edge cases. With `image.enabled: false` the `/_mochi/asset/…` route still serves the file, because that route is plain static serving and registers independently of the flag. The transform does not run: `` falls back to the raw static URL, while the `` keeps the size's declared `width`/`height`, so the browser scales the full-size original into that box. And the [`image:url`](/docs/extensions/#imageurl) CDN-rewrite filter runs on minted transform URLs only, so the no-size static URL (`hero.src`) bypasses it. Use a `size` if you need local assets routed through the filter. ### `getImageUrl` — deferred URLs `getImageUrl(src, size)` returns an encrypted URL. It is synchronous and near-instant. No fetch happens until the browser requests it. ```ts import { getImageUrl } from 'mochi-framework'; const url = getImageUrl('https://example.com/photo.jpg', 'thumbnail'); // → /_mochi/image/photo-thumbnail.webp?p= const original = getImageUrl('https://example.com/photo.jpg'); // no size → the original ``` The URL is relative by default. To serve images from a CDN, register the [`image:url`](/docs/extensions/#imageurl) filter. ### `getImageAttrs` — URL + declared dimensions `getImageAttrs(src, size?)` returns `getImageUrl` plus the size's declared `width`/`height`. Synchronous, server-only. ```ts import { getImageAttrs } from 'mochi-framework'; const { url, width, height } = getImageAttrs(src, 'thumbnail'); // → { url: '/_mochi/image/…', width: 200, height: 200 } ``` With `fit: 'inside'` (the default), the served image's real dimensions can be smaller than the declared ones. If the aspect ratio matters for layout, add CSS such as `height: auto` so the declared attributes only reserve space. ### `getImage` — inline bytes + metadata When you need the transformed bytes server-side (OG images, inlining, a dimension probe), `getImage(src, size)` runs the size inline and returns bytes plus metadata. It shares the same disk cache. Prefer `getImageUrl` for anything that ends up in an ``. ```ts import { getImage } from 'mochi-framework'; const { bytes, contentType, width, height, format } = await getImage(src, 'thumbnail'); const original = await getImage(src); // no size → the cached original bytes ``` ### Placeholder APIs The ThumbHash blur is also available directly. All three are server-only and share the image cache: ```ts import { getImagePlaceholder, imagePlaceholder, warmImagePlaceholder } from 'mochi-framework'; const blur = await getImagePlaceholder(src); // compute-and-cache, blocking; data: URL or null const maybeBlur = await imagePlaceholder(src); // non-blocking read; cached blur or null warmImagePlaceholder(src); // fire-and-forget compute ``` ### Caching & TTL Mochi stores the original's encoded bytes and its stale-while-revalidate timers on disk (`cacheDir`), so the cache survives restarts. There is one TTL — the original's — and variants follow it: ```ts await Mochi.serve({ image: { timeToStale: 14_400_000, // serve fresh for 4h timeToEvict: 86_400_000, // re-fetch source after 1 day }, routes, }); ``` - **Fresh** (within `timeToStale`): served from disk. - **Stale** (between `timeToStale` and `timeToEvict`): served immediately, source re-fetched in the background. - **Expired** (past `timeToEvict`): re-fetched synchronously. A background janitor reclaims entries past `timeToEvict` every `sweepIntervalMs` (default 1h). Set `sweepIntervalMs: 0` to disable it. Served images carry an `ETag` and a `Cache-Control` derived from the cache window (`public, max-age=, stale-while-revalidate=`). In development no `Cache-Control` is sent, so edits and `invalidateImage()` calls always show up on the next request. ### Custom cache storage Mochi backs the image cache with `FileStorage` under `cacheDir` by default. Pass `storage` to swap in a different backend, for example `MemoryStorage` — the only other built-in one; anything else (SQLite, Postgres, Redis, …) means implementing `Storage` yourself. See [Persistence](/docs/persistence/) for how this compares to other Mochi features. ```ts import { MemoryStorage } from 'mochi-framework'; await Mochi.serve({ image: { storage: new MemoryStorage({ maxAge: 86_400_000 }), // must be >= timeToEvict timeToStale: 14_400_000, timeToEvict: 86_400_000, sizes: { thumbnail: { width: 200, height: 200 } }, }, routes, }); ``` **In-memory image caching trades disk for RAM.** Every cached original and variant lives in process memory, so cache size adds to your process's memory footprint. The cache is lost on every restart, so the first request after a restart re-fetches and re-transforms. ### Invalidation ```ts import { invalidateImage } from 'mochi-framework'; await invalidateImage(src); // mark stale: next request serves cached bytes, re-fetches in background await invalidateImage(src, { hard: true }); // mark expired: next request blocks for a fresh re-fetch ``` `invalidateImage()` operates on the shared original, so it cascades to every variant and the ThumbHash placeholder. ### Configuration Configure under `Mochi.serve({ image: { … } })`. Every option is optional. | Option | Default | Notes | | ---------------------- | ----------------------- | ------------------------------------------------------------------------- | | `sizes` | `{}` | Named transform recipes | | `enabled` | `true` | `false` unmounts the endpoint; URL helpers then return the raw source URL | | `cacheDir` | `./.mochi/image-cache` | Must not be under `publicDir`; ignored when `storage` is set | | `storage` | `FileStorage(cacheDir)` | Override the cache backend | | `defaultFormat` | `webp` | Used when a size omits `format` | | `defaultQuality` | `80` | Used when a size omits `quality` | | `outputFormats` | all four | Allowed output formats | | `allowedHosts` | any public host | Exact host or `*.example.com` | | `blockPrivateNetworks` | `true` | Reject private/loopback/link-local addresses | | `fetchTimeoutMs` | `10_000` | Upstream fetch timeout | | `maxResponseBytes` | `20 MB` | Hard source-size cap | | `maxPixels` | `50_000_000` | Decompression-bomb guard | | `timeToStale` | `14_400_000` | Cache time-to-stale (ms); variants follow it | | `timeToEvict` | `86_400_000` | Cache time-to-evict (ms); variants follow it | | `sweepIntervalMs` | `3_600_000` | Background cache-janitor interval; `0` disables | | `compressPayload` | `true` | Deflate the encrypted URL payload | **Encryption is the security boundary.** The payload is encrypted with a key derived from your `MOCHI_KEY`, so only your server can mint URLs and the source URL stays hidden. If you pass a **user-controlled** `src` into `getImageUrl()`/`getImage()`, keep `blockPrivateNetworks` on (the default) and prefer an `allowedHosts` allowlist so a user cannot proxy requests to internal services. Upstream redirects are followed, but every hop is re-validated against those same checks. Cap the hop count with the [`image:maxRedirects`](/docs/extensions/#imagemaxredirects) filter. A full-size original that is SVG (or any non-raster type) is served as a download rather than inline. See the [Named sizes demo](/demos/image-pipeline/) and the [Image demo](/demos/image/). --- title: 'View Transitions' slug: view-transitions description: 'Animate full-page navigations with the browser cross-document View Transitions API and zero JavaScript.' --- ## View Transitions `` opts your app into the browser's cross-document [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API), animating full-page navigations with **zero client JavaScript**. Mochi is an MPA, so every navigation is a real page load. The browser does the work. You declare the animation. Render it from a component that appears on **every** page — for example, a shared page shell. Both the page you leave and the page you land on must opt in. ```svelte ``` Navigate between pages and they crossfade. That is the whole setup. Render exactly **one** `` per page. Two instances would emit the same global `@keyframes` names and competing rules. If a second one renders, it logs a warning and emits nothing. The first instance wins. ### Props | Prop | Type | Default | Description | | ---------------------- | -------------------------------------------------- | -------- | --------------------------------------------------------------------- | | `type` | `'fade' \| 'slide' \| 'scale' \| 'blur' \| 'flip'` | `'fade'` | The transition preset. | | `custom` | `{ out?: string; in?: string }` | — | Custom keyframe bodies. Overrides `type`. | | `duration` | `number` (ms) | `250` | Animation duration. | | `easing` | `string` | `'ease'` | The animation timing function. | | `regions` | `string \| string[]` | — | Confine the animation to elements with these `view-transition-name`s. | | `keepElementSelectors` | `string \| string[]` | — | CSS selectors for persistent chrome to hold still across navigations. | ```svelte ``` Five presets ship built in: `fade`, `slide`, `scale`, `blur`, and `flip`. They animate the page root, so they apply to any page with no per-element setup, and reduced-motion users get no animation automatically. ### Custom transitions Pass `custom` to bring your own animation. `out` and `in` are the **body** of each keyframe for the page you leave and the page you land on. Mochi wraps each into an `@keyframes` for you. ```svelte ``` Either side is optional. `custom` composes with `duration`, `easing`, `regions`, `keepElementSelectors`, and reduced-motion. When `custom` is set it overrides `type`, so you can leave `type` unset. ### Animating only part of the page The API always snapshots the whole viewport. You can scope which parts animate. Pass `regions` to confine the transition to elements you gave a [`view-transition-name`](https://developer.mozilla.org/en-US/docs/Web/CSS/view-transition-name). Everything else swaps instantly. ```svelte
``` An empty array (`regions={[]}`) disables the animation entirely. Each `view-transition-name` must be unique per document. Do not reuse one name across several elements on the same page. ### Keeping elements still To hold persistent chrome — a banner, sidebar, or header — still while the rest of the page transitions, pass `keepElementSelectors` a list of CSS selectors. Mochi assigns each selector a unique `view-transition-name` and emits the freeze CSS. Render the same list on every page. ```svelte ``` Each selector must match exactly one element per page. `view-transition-name`s are unique per document, so a selector that matches several elements breaks the transition. Cross-document view transitions are supported in current Chromium browsers. Where unsupported, the navigation happens with no animation. into a layout to animate navigations with zero JavaScript." }, { href: "/demos/custom-transitions/", title: "Custom Transitions", hook: "Supply your own @keyframes to via custom={{ in, out }}." }, ]} /> --- title: 'RawScript' slug: raw-script ogTitle: 'Inlining a file at SSR time with RawScript' description: 'Inline the raw contents of a file into the page at SSR time, addressed by a working-directory-relative path.' --- ## RawScript **Experimental.** This API is new and may change in a future release. `` reads a file at SSR time and prints its contents verbatim with `{@html}`. Mochi resolves `src` relative to the **working directory** — the same convention as the paths you pass to `Mochi.page('./src/...')`. ```svelte ``` Use it when you have a chunk of pre-authored JavaScript, JSON, or CSS on disk that you want inlined into the document — an inline ` ## Captcha
The MochiCaptcha slide-to-verify widget in its default styling
The widget with no CSS applied — every colour falls back to a built-in default.
`` is a slide-to-verify widget that gates form submissions without a third-party service or tracker. Mint a challenge in `serverProps`, render the component, verify in the action. To gate whole routes instead of forms, the same proof-of-work backs [Protection Mode](/docs/protection/). ```ts // src/routes.ts import { Mochi, fail, success, mintCaptcha, verifyCaptcha } from 'mochi-framework'; export const routes = { '/contact': Mochi.page('./src/Contact.svelte', { serverProps: () => ({ captcha: mintCaptcha() }), actions: { send: async ({ formData }) => { const captcha = await verifyCaptcha(formData); if (!captcha.ok) { return fail(400, { error: captcha.error }); } return success(); }, }, }), }; ``` `mintCaptcha()` returns `{ token, bits, solveBudgetMs }`. Spread it onto the component. The widget adds its own `captcha_token` and `captcha_pow` hidden inputs to the surrounding form, so `verifyCaptcha(formData)` needs nothing else. ```svelte ``` ### Hydration The captcha runs entirely in the browser — the slider, the hash chain, and the proof-of-work. The server renders only a blank spacer the size of the widget, and the slider appears in its place once it hydrates. Wire it up one of two ways: - **Hydrate the captcha itself** — put `mochi:hydrate` on it, as above. - **Hydrate the surrounding subtree** — if the captcha sits inside a component you hydrate, it hydrates with it. The subtree route is the common one. The moment you attach [`enhance`](/docs/progressively-enhancing-forms-with-enhance/) to the form or bind `verified` to gate the submit button, you hydrate the form component anyway, and the captcha rides along. A binding cannot cross an island boundary, so `bind:verified` works only this way — the captcha and the code binding it must hydrate together. ```svelte
``` `bind:verified` is optional. The server rejects an unsolved submission either way. With JavaScript off, the spacer shows a `