` is injected into every page.
- **Error overlay** — build and runtime errors render on top of the page.
- **Bundle stats** — a JSON report is served at `${assetPrefix}/client/stats`.
- **Stack traces on `error` events** — `error` payloads include `stack`. In production `stack` is `undefined`.
Never run development mode in production — whether from a stray `NODE_ENV=development` or a forced `development: true`. Stack traces leak through the `error` event and the error overlay, the file watcher holds open file descriptors, and SSR bundles recompile on every change instead of loading from the prebuilt manifest.
### Overriding the mode
Pass `development` to force a mode regardless of the environment:
```ts
// file: src/index.ts
await Mochi.serve({
development: true, // ignores NODE_ENV
routes,
});
```
Reach for it sparingly. Everything that runs outside `Mochi.serve()` still reads `NODE_ENV` — [`isDev`](/docs/environment-constants/#isdev) in module top-level code, and [`setupTailwind`](/docs/tailwind/#dev-rebuilds), which you call yourself — so an override makes those disagree with the running server. Mochi warns at boot when it contradicts `NODE_ENV=development`.
### Live reload
Set `liveReload: false` to keep the debug bar and file watcher but skip the `/__mochi_live_reload` WebSocket and the `mochi-live-reload` web component. Defaults to whatever `development` is.
### File watcher
The watcher always covers `src/` and `public/`. Extend it with `additionalWatchPaths`:
```ts
// file: src/index.ts
await Mochi.serve({
additionalWatchPaths: ['../content', './docs'],
routes,
});
```
`additionalWatchPaths` is additive. Paths that do not exist on disk are skipped silently.
### Route handler HMR
**Route handler code** — `Mochi.api` handlers, `serverProps` resolvers, form `actions`, `Mochi.ws` handlers, `Mochi.sse` handlers — is hot-swapped without a restart. The watcher builds your entry (`src/index.ts`) to discover its transitive dependencies. When any change, it rebuilds the entry, re-reads the `routes` from its `Mochi.serve()` call, and updates the running server in place. Adding, removing, and editing route patterns all work without a restart. WebSocket connections stay open. The browser reloads to pick up updated `serverProps`.
Each reload re-evaluates the **entire first-party dependency graph** — every `let` / `const` at module scope is recreated. This is not just a stale-cache annoyance: a module-scoped singleton that holds an **OS resource** (a DB pool, a `setInterval`, a file watcher, an SMTP pool) is re-created on every save while the previous instance is orphaned with its resource still open. Nothing closes it, so usage grows one leak per save until a hard failure — a Postgres pool, for example, exhausts `max_connections` after a dozen saves and takes the database down for every client.
Moving the singleton "into a module the entry imports" does **not** help — that module is first-party, so it is exactly what gets re-evaluated. Hold the resource with [`pinGlobal`](/docs/utility-helpers/#process-singletons) instead, which pins one instance on `globalThis` that survives every reload:
```ts
// file: src/db/client.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, idleTimeout: 30 }));
}
```
Call `getSql()` wherever you need the pool — every call returns the same instance, so route handlers and `serverProps` share the one set of connections:
```ts
// file: src/routes.ts
import { getSql } from './db/client';
export const routes = {
'/users': Mochi.page('./src/Users.svelte', {
serverProps: async () => ({
users: await getSql()`SELECT id, name FROM users`,
}),
}),
};
```
Namespace your key with an `app:` prefix — the framework uses `__mochi_*__` for its own pins. Setting `idleTimeout` is worth it regardless: it bounds the damage from any pool that still escapes.
Mochi warns once per dev session — via the [`recompile:module-churn`](/docs/events/#recompilemodule-churn) event — after the entry has been re-imported ten times, printed by `consoleLogger()` as a `warn`-level `HMR` line. If you have deliberately accepted the churn (or your resources are all held with `pinGlobal`), silence just that line with a [`consoleLogger:line`](/docs/extensions/#consoleloggerline) filter — the same mechanism the framework uses to hide its own internal routes:
```ts
// file: src/index.ts
await Mochi.serve({
filters: {
'consoleLogger:line': (line, { source }) => (source.name === 'recompile:module-churn' ? null : line),
},
routes,
});
```
Returning `null` drops the line; returning it unchanged keeps it. This suppresses only the churn warning — every other log line is untouched.
### `file:change` event
Every watcher event re-emits on `mochiEvents` as `file:change`. Use it to invalidate your own caches:
```ts
// file: src/lib/docs-cache.ts
import { mochiEvents } from 'mochi-framework';
mochiEvents.setHandler('docs:cache-clear', 'file:change', ({ path }) => {
if (path.endsWith('.md')) clearMyMarkdownCache();
});
```
**Use `setHandler` here, not `.on()`.** In dev, the module holding this handler can be re-run when Mochi recompiles. Each `.on()` call leaves behind another duplicate listener. `setHandler` registers by name, so re-running replaces the previous one.
In production the watcher never starts and `file:change` never emits.
### HMR rebuild logger lines
The built-in logger prints a structured line per save:
- `BUILD` — one per page compiled, plus a summary with total file count and duration.
- `BNDL` — one per `buildClientBundle()` call.
- `HMR ` — one per rebuild cycle. Note shows the trigger, `pages=N`, and `bundles=N`.
A healthy `bundles=` for a non-CSS save is `1`. A higher number means a regression to per-page bundling, which is O(N²) work for N hydratable pages.
**Use `logger: { compile: false }` to suppress rebuild lines.** It drops only `BUILD` / `BNDL` / `HMR` output. Disabling the whole logger loses other diagnostics.
### Barrel-import warning
Mochi warns when a dependency drags a large module into the build graph that is then almost entirely tree-shaken away:
```ts
import { Sun } from '@lucide/svelte'; // ❌ parses the whole ~100 KB re-export index every rebuild
import Sun from '@lucide/svelte/icons/sun'; // ✅ pulls only the one icon
```
Bun tree-shakes the barrel's modules, but it still re-parses the package's big re-export file on every rebuild, which slows HMR. In dev the warning fires once per package. A production `mochi-framework build` runs the same check and collapses every offender into one grouped summary line.
Tune or silence it with `barrelWarnings` on `Mochi.serve()`:
```ts
await Mochi.serve({
barrelWarnings: false, // silence entirely
// or keep it on but suppress a package / raise the threshold:
barrelWarnings: { ignore: ['@lucide/svelte'], minBytes: 100 * 1024 },
routes,
});
```
`minBytes` defaults to 50 KB. For richer logic than a static `ignore` list, register the [`barrel:warn` filter](/docs/extensions#barrelwarn).
---
title: 'Discord'
slug: discord
ogTitle: 'Join the Mochi Discord'
description: 'Join the Mochi community on Discord.'
---
## Discord
Questions, bug reports, ideas, or want to see what others are building?
Join the community: **[mochi.fast/discord](/discord/)**.
### Scan to join on your mobile
---
title: 'Debug bar'
slug: debug-bar
description: 'A floating dev toolbar showing hydration metrics, request data, island breakdown, and bundle stats.'
---
## Debug bar
A floating toolbar pinned to the bottom-right of every page in development. It surfaces hydration cost, request metadata, runtime warnings, and a link to the bundle stats page. `Mochi.serve()` mounts it whenever `development: true`, with nothing to wire up.
The bar as it sits on a page in development. The toolbar has its own dark styling and does not follow the site's theme.
In production (`development: false`) the toolbar mount point, its entry script, and the per-request `window.__mochi_debug` payload are stripped from the HTML. The bar adds zero bytes to production responses. See [development mode](/docs/development-mode/) for the rest of what dev mode turns on.
### Buttons
| Button | Opens |
| ---------------- | ----------------------------------------------------------------------------------- |
| Status dot | Live-reload connection state — green pulse when connected, red when dropped. |
| `Request` | Matched route pattern, pathname, params, response size, `Set-Cookie`s, headers. |
| `Info` | Mochi / Svelte / Bun versions and a snapshot of the active `Mochi.serve()` config. |
| `Islands` | Per-island breakdown with mode tag, props size, and a locate-on-page button. |
| `Warnings` | Anything pushed through `window.__mochi_warn(msg)`. Hidden when the queue is empty. |
| `Bundle Stats ↗` | Opens the bundle stats page (`/_mochi/client/stats`) in a new tab. |
| `Cache` | Empty the on-disk [image cache](/docs/images/) in one click. |
| `⚙` | Configure which panel buttons appear in the bar. |
### Configuring panels
The cogwheel opens a checklist of the panels. Unchecked panels disappear from the bar. The choice persists across reloads in `localStorage` under `mochi:debug:hidden-panels`. At least one panel always stays enabled.
### Islands panel
Lists every `
` and `` on the page, grouped by type. Each row shows the component name, its hydration mode, and props size. Click a row to expand the props as syntax-highlighted JSON. Click the crosshair icon to scroll to the island and flash a cyan outline for ~1.5s.
The Islands panel. The summary row counts hydrated and server islands separately, and rows sharing a props payload carry a shared badge.
The `Islands` button shows a running total props size and changes color past two thresholds — yellow above **10 KB**, red above **100 KB**. Props payload is the dominant tax on hydration. See [passing props to islands](/docs/island-props/) for how to keep payloads small.
When two or more islands ship the exact same props payload, Mochi hoists it into a single shared `
## Testing
Testing support is **experimental**. The `bun test` runner works well for plain unit tests today. The full-app helper below is new, and its API may change.
### Unit tests
Test pure functions, stores, and any non-server logic directly with [`bun test`](https://bun.sh/docs/cli/test) — no Mochi-specific setup:
```ts
// src/lib/slugify.test.ts
import { expect, test } from 'bun:test';
import { slugify } from './slugify';
test('lowercases and dasherizes', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
```
```sh
bun test
```
### Full-app tests
A test that boots a real server with `Mochi.serve()` must run **one file per process**. `Mochi.serve()` allows one instance per process, so two server-booting test files in the same `bun test` run throw `Mochi.serve() has already been called.`
`runTests` solves this. It globs `src/**/*.test.ts` and runs each file in its own `bun test` process, parallelised across CPU cores. Add a small script:
```ts
// scripts/run-tests.ts
#!/usr/bin/env bun
import { runTests } from 'mochi-framework';
await runTests();
```
Point your `test` script at it:
```json
// package.json
{
"scripts": {
"test": "bun scripts/run-tests.ts"
}
}
```
Each file gets a fresh process, so every test can call `Mochi.serve({ port: 0 })` without colliding:
```ts
// src/routes.test.ts
import { afterAll, beforeAll, expect, test } from 'bun:test';
import type { Server } from 'bun';
import { Mochi } from 'mochi-framework';
import { routes } from './routes';
let server: Server;
beforeAll(async () => {
server = await Mochi.serve({ port: 0, logger: { enabled: false }, routes });
});
afterAll(() => server.stop(true));
test('GET / renders', async () => {
const res = await fetch(`http://localhost:${server.port}/`);
expect(res.status).toBe(200);
});
```
#### Options
`runTests(options?)` accepts:
- **`dir`** — package root to scan and run tests from. Defaults to the current working directory.
- **`sequential`** — files (relative to `dir`) that must run on their own, after the parallel batch, for tests that cannot share machine state:
```ts
await runTests({ sequential: ['src/liveReload.test.ts'] });
```
`runTests` reprints a recap at the end — every failed file, the failing test names, and their error output — and exits with code `1` if any file failed.
### Bun workspaces: use the hoisted linker
In Bun **workspaces**, `bun install` defaults to the isolated linker. Combined with `bun test`, this trips a Bun bug: a second `Bun.build()` in the test process fails with `EISDIR reading file` on a dependency inside `node_modules/.bun`. Pin the hoisted linker in your workspace root `bunfig.toml`:
```toml
# bunfig.toml
[install]
linker = "hoisted"
```
Then delete `node_modules` and reinstall. Single-package apps — including everything scaffolded by `create-mochi` — install hoisted by default and are unaffected.
---
title: 'Type checking'
slug: type-checking
description: 'Make svelte-check and your editor understand the mochi:* directives with the ambient types and a warningFilter in svelte.config.js.'
---
## Type checking
Scaffolded projects ship a `typecheck` script that runs [`svelte-check`](https://www.npmjs.com/package/svelte-check) and then `tsc`:
```sh
bun run typecheck
```
Both tools, and the Svelte VS Code extension, read `.svelte` files raw. They see the `mochi:*` directives that Mochi strips before the Svelte compiler runs, so two small additions keep them quiet.
### Ambient types
Reference `mochi-framework/ambient` from a `.d.ts` file in your project. Scaffolds do this in `src/global.d.ts`:
```ts
// file: src/global.d.ts
///
```
It declares Mochi's asset imports (`*.css`, `*.md`, images) and whitelists the directives on every HTML element and on every component, so ` ` type-checks without the component declaring anything.
#### Directives on components
svelte2tsx checks a call site against the component's own `$props()` type. Mochi widens that type where svelte2tsx builds it, so the `mochi:*` keys and their option objects are known on every component. Generic components (`
```
Projects scaffolded before 0.10.0 patch `svelte-check` instead (`patches/svelte-check@….patch` via `patchedDependencies`). The patch only reaches the CLI — editors bundle their own copy of svelte2tsx and keep reporting the error. After upgrading, delete the `patches/` directory and the `patchedDependencies` entry in `package.json`; the ambient types cover both.
### `attribute_illegal_colon`
The Svelte compiler warns about the colon in every `mochi:*` attribute. Filter it in `svelte.config.js`; `svelte-check` and the VS Code extension both read that file:
```js
// file: svelte.config.js
export default {
compilerOptions: {
experimental: { async: true },
warningFilter: (warning) => warning.code !== 'attribute_illegal_colon',
},
};
```
Scaffolds ship this filter. If yours predates it, adding the line replaces the `--compiler-warnings 'attribute_illegal_colon:ignore'` flag in the `typecheck` script.
---
title: 'Tailwind'
slug: tailwind
ogTitle: 'Tailwind CSS v4 in a Mochi app'
description: 'Integrate Tailwind CSS v4 into a Mochi app with the setupTailwind helper.'
---
## Tailwind
Experimental — the `mochi-framework/tailwind` API may change.
Drive Tailwind v4 with its Node API. Mochi ships an opt-in helper at `mochi-framework/tailwind` that compiles your input CSS at server startup and re-runs on file changes in dev. Then `import` the generated file from any `.svelte` and Mochi's [CSS-import bundler](/docs/css-imports/) links it scoped to the page.
### Setup
1. Install Tailwind alongside its Node and scanner packages:
```sh
bun add tailwindcss @tailwindcss/node @tailwindcss/oxide
```
2. Write an input CSS that imports the layers you want and tells Tailwind where to scan:
```css
/* file: src/styles/app.css */
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/utilities.css';
@source './*.svelte';
```
3. Call `setupTailwind` at module scope in `src/index.ts`, before `Mochi.serve()`. Top-level `await` ensures the generated CSS exists for both the build CLI and the dev server:
```ts
// file: src/index.ts
import { Mochi } from 'mochi-framework';
import { setupTailwind } from 'mochi-framework/tailwind';
await setupTailwind({
input: './src/styles/app.css',
output: './src/styles/app.generated.css',
minify: process.env.NODE_ENV !== 'development',
});
await Mochi.serve({
routes: {
'/': Mochi.page('./src/Home.svelte'),
},
});
```
4. `import` the generated file from any `.svelte` that uses Tailwind classes:
```svelte
Click
```
5. Add `app.generated.css` to `.gitignore` — it is a build artifact.
The bundler strips the import from the JS bundle and serves the CSS at `/_mochi/import-css/.css`. The ` ` is added to every page that transitively imports it. Pages that do not reference it ship no Tailwind. See [CSS imports](/docs/css-imports/).
### `setupTailwind` options
| Option | Default | Meaning |
| -------- | -------------------- | ------------------------------------------------------------- |
| `input` | — | Path to the input CSS (`@import`s and `@source` rules). |
| `output` | — | Path where the generated CSS is written, stable for `import`. |
| `base` | directory of `input` | Anchors `@source` patterns. |
| `minify` | `false` | Minify the optimised output. Set from `process.env.NODE_ENV`. |
`@source` paths resolve against `base`. `./*.svelte` matches files next to `app.css` only. Use `**/*.svelte` for nested folders.
### Dev rebuilds
In development, `setupTailwind` subscribes to `file:change` on `mochiEvents` and rebuilds on `.svelte` / `.ts` / `.js` / `.html` / `.md` / `.svx` / `.css` changes. The resulting write goes through Mochi's CSS fast-path — a stylesheet swap, not a full SSR rebuild. The watcher attaches only when `process.env.NODE_ENV === 'development'`.
### Production builds
`setupTailwind` static-imports `@tailwindcss/oxide`, a native module. If your production runtime image uses a different libc than the install image, the binding installed at build time fails to load at runtime and the server crashes at startup with `Cannot find native binding`.
Generate the CSS at build time and dynamic-import the helper so production never loads oxide:
```ts
// file: src/index.ts
if (process.env.NODE_ENV === 'development') {
const { setupTailwind } = await import('mochi-framework/tailwind');
await setupTailwind({
input: './src/styles/app.css',
output: './src/styles/app.generated.css',
});
}
```
Pair it with a prebuild script that compiles the CSS ahead of [`mochi-framework build`](/docs/cli/#build):
```ts
// file: scripts/prebuild.ts
import { compileTailwind } from 'mochi-framework/tailwind';
await compileTailwind({
input: './src/styles/app.css',
output: './src/styles/app.generated.css',
minify: true,
});
```
```json
"scripts": {
"build": "bun scripts/prebuild.ts && mochi-framework build"
}
```
**Guard the `mochi-framework/tailwind` import dynamically.** A static import loads the oxide native binding even when the call site is gated by `NODE_ENV`. Put the `import()` inside the development guard so the binding is never resolved in production.
### Preflight and resets
The example imports `tailwindcss/utilities.css` **without** `layer(utilities)`. If your shell's CSS ships an unlayered universal reset, wrapping utilities in `layer(utilities)` lets the unlayered reset clobber `.p-6`, `.mt-2`, and so on, because unlayered styles beat layered ones in the cascade.
The example also skips Tailwind's preflight so the stylesheet does not reset unrelated UI. The cost is that user-agent defaults leak through, so `` keeps its rounded macOS pill shape. Add a small reset:
```css
/* file: src/styles/app.css */
button {
appearance: none;
background: transparent;
border: 0;
font: inherit;
color: inherit;
cursor: pointer;
}
```
To opt back in, `@import 'tailwindcss/preflight.css' layer(base);`.
Preflight resets every element on every page that imports the stylesheet. On a multi-page site, scope your Tailwind CSS to a single page or accept that preflight resets shared chrome too.
---
title: 'Svelte Shaker optimization'
slug: svelte-shaker
description: 'Optimize and slim .svelte sources before compilation with the whole-program svelte-shaker optimizer.'
---
## Svelte Shaker
[svelte-shaker](https://github.com/baseballyama/svelte-shaker) is a whole-program optimizer that slims `.svelte` **source** before the Svelte compiler runs. It folds props that never vary, removes the dead branches that folding opens up, and narrows unused CSS. The result is less generated code per component and smaller bundles.
It is opt-in and ships as a separate package, so apps that do not use it never install the engine. Add it:
```sh
bun add -d @mochi-framework/svelte-shaker
```
Then enable it with `optimize: true` on `Mochi.serve()`:
```ts
// src/index.ts
await Mochi.serve({
port: 3000,
optimize: true,
routes: {
'/': Mochi.page('./src/Home.svelte'),
},
});
```
Shaking runs in **production only**. It is a whole-program pass — folding in one component can change when an unrelated component's call site changes — so it cannot be reused per file across hot reloads. In development the flag is ignored and components compile from their original source.
### Excluding components
If the shaker mis-transforms a component or you hit build-time errors, pass `{ exclude }` with cwd-relative globs to compile those files from their original source. Excluding is safe — the whole-app scan still covers an excluded file as a call site of the components that import it. Only its own output is left unshaken.
```ts
await Mochi.serve({
optimize: {
enabled: true,
exclude: ['src/components/ThemeToggle.svelte', 'src/legacy/**'],
},
routes: {
'/': Mochi.page('./src/Home.svelte'),
},
});
```
The svelte-shaker package supports Svelte 5 Runes syntax only, not Svelte 4 legacy syntax.
### Disabling temporarily
Pass `enabled: false` inside the options object to skip shaking while keeping the rest of your config visible:
```ts
await Mochi.serve({
optimize: {
enabled: false,
exclude: ['src/components/ThemeToggle.svelte'],
},
routes: {
'/': Mochi.page('./src/Home.svelte'),
},
});
```
This equals `optimize: false` but preserves the options so you can re-enable with one toggle.
### Size report
When shaking runs, Mochi logs a per-component before→after source-byte breakdown:
```
svelte-shaker: slimmed 15 of 86 component(s), 1 excluded
svelte-shaker: source size before → after
src/components/Sidebar.svelte 3.21 kB → 2.74 kB (-14.6%)
…
total (15 changed) 48.9 kB → 41.2 kB (-15.7%)
```
`slimmed N of M` reports how many components the shaker changed versus the total scanned.
### Scope
Only components under `./src` are scanned. Prop folding is sound only when every call site of a component is in scope, so components imported from outside `./src` (a shared package) are left untouched. If the add-on is not installed, or shaking fails, Mochi logs a warning and falls back to the original source.
---
title: 'rsvelte compiler'
slug: rsvelte
ogTitle: 'rsvelte, the Rust Svelte compiler'
description: 'Swap the JavaScript Svelte compiler for rsvelte, a Rust port built on OXC, to cut compile time.'
---
## rsvelte
[rsvelte](https://github.com/baseballyama/rsvelte) is a Rust port of the Svelte 5 compiler built on [OXC](https://oxc.rs/). Mochi can route component compilation through it instead of the JavaScript `svelte/compiler`, which speeds up cold builds and dev rebuilds.
It is opt-in. Install the adapter package:
```sh
bun add -d @mochi-framework/rsvelte
```
Then pass `svelteCompiler`:
```ts
// src/index.ts
await Mochi.serve({
port: 3000,
svelteCompiler: 'rsvelte',
routes: {
'/': Mochi.page('./src/Home.svelte'),
},
});
```
The default is `'svelte'`.
rsvelte is pre-1.0. Its authors note that APIs and behavior may change without notice.
### Without touching code
`MOCHI_SVELTE_COMPILER` overrides the option, which is the easy way to A/B a build:
```sh
MOCHI_SVELTE_COMPILER=rsvelte bun run build
MOCHI_SVELTE_COMPILER=svelte bun run build
```
Mochi logs which compiler it resolved (`Svelte compiler: rsvelte@0.2.8+svelte5.56.4`) once at startup. That line is also how you confirm a build ran on rsvelte, since the fallback is silent.
### Benchmark
This benchmark shows the gains on a small application (the `demos` preset from the CLI):
| phase | svelte | rsvelte | delta |
| --------------- | -----: | ------: | -------: |
| total | 384ms | 289ms | **−25%** |
| `ssr-build` | 213ms | 137ms | **−36%** |
| `client-bundle` | 40ms | 26ms | **−35%** |
(10 cold-start runs, median value)
### What runs on rsvelte
Only `compile()` and `compileModule()` run on rsvelte. **Parsing and preprocessing always stay on the official compiler**, so islands, `mochi:hydrate*`, `mochi:defer`, and user preprocessors behave identically either way.
### If it cannot load
Prebuilt binaries cover macOS arm64/x64, Linux x64/arm64 (glibc), and Windows x64 (MSVC). There is no musl build, so Alpine-based images are unsupported. When the package is missing or its binary fails to load, Mochi logs a warning and compiles with `svelte/compiler`.
### Known divergences
Production output should generally be byte-identical to `svelte/compiler`. Three differences remain:
- **`cssHash` and `warningFilter`** in `svelte.config.js` are functions and cannot cross the native boundary. They are stripped with a one-time warning. Use rsvelte's `cssHashOverride: ''` to force a fixed CSS hash.
- **Dev-only instrumentation** is not always reproduced. This affects development builds only. Production output matches.
- **`compileModule()`** emits a `vVERSION` placeholder in its header comment and different printer whitespace. Semantically identical.
---
title: 'Prerendered modules'
slug: prerender
description: 'Name a module *.prerender.ts to run it at build time and inline its exports, so the code that produced them never ships.'
---
## Prerendered modules
A module named `*.prerender.ts` (or `.prerender.js`) runs while your app compiles. The bundler replaces it with its exported values, so the module, and everything it imports, is left out of the bundle.
This prerenders a module, not a page — Mochi has no SSG mode, and every request still renders fresh.
```ts
// src/demos/url/sources.prerender.ts
import { loadSources } from '../../components/utils.ts';
import { files } from './files.ts';
export const sources = await loadSources(files);
```
```svelte
```
What the bundle holds is the finished value, not the code:
```js
const __mochi_export_0__ = [{ label: 'App.svelte', html: '… ' }];
export { __mochi_export_0__ as sources };
```
`loadSources` and `files` are gone, and so is anything they imported — which is the point: a syntax highlighter, a markdown parser, or a database client used only to produce the value stops being a runtime dependency.
The suffix is the whole convention, like [`.server.ts`](/docs/server-only-imports/). Top-level `await` works; the build awaits it. Import the module with the extension (`./sources.prerender.ts`) from a `.svelte` or `.md` file, or from a `.ts` module one of them imports. Only the component bundler replaces it: imported from the server entry (`routes.ts`, an API handler), it runs as a plain module, and `moduleRef()` throws.
### What a prerendered module may export
Anything [devalue](https://github.com/sveltejs/devalue) can serialize: plain data, `Date`, `Map`, `Set`, `RegExp`, `BigInt`, `undefined`, cycles, and repeated references. Functions, class instances, and promises are a compile error naming the export:
```
src/lib/table.prerender.ts exports "rows", which cannot be inlined: Cannot stringify a function at rows[0].format.
```
Each export is serialized on its own, so an object shared between two exports is inlined twice.
### Returning components with `moduleRef()`
A component cannot be serialized. `moduleRef()` marks a module to import instead, and the build turns each marker into a real `import`:
```ts
// src/lib/docComponents.prerender.ts
import { moduleRef } from 'mochi-framework';
import type { Component } from 'svelte';
import { loadDocs } from './docs';
export const docComponents: Record = Object.fromEntries((await loadDocs()).map((d) => [d.slug, moduleRef(`../../docs/${d.filename}`)]));
```
becomes:
```js
import __mochi_ref_0__ from '../../docs/10-intro.md';
const __mochi_export_0__ = { intro: __mochi_ref_0__ };
export { __mochi_export_0__ as docComponents };
```
Specifiers resolve relative to the `.prerender.ts` file. This replaces generating a barrel file into your source tree before every build, and the map stays typed against the real module.
A prerendered module cannot `import` a `.svelte`, `.md`, or `.svelte.ts` file itself — it runs outside the bundler, where Svelte files have no loader. The build rejects the import and points at `moduleRef()`.
### In dev
The module is evaluated at build time in dev too, and re-evaluated on every rebuild. An edit to the module or to anything it imports rebuilds the pages that use it. A file it only _reads_ — a directory of markdown, say — is outside the import graph: adding or removing a source or data file (`.md`, `.svelte`, `.ts`, `.json`, …) rebuilds every prerendered module, while an edit to one shows up on the next rebuild of a page that imports the module.
Each dev evaluation gets fresh instances of the module's own imports, but the running server keeps its own. A memo that must be shared between the two, say a parsed-docs cache the prerendered module fills and a route reads, has to live on `globalThis` — use `pinGlobal(key, create)` from `mochi-framework`.
### What the build reports
`mochi-framework build` prints every module it inlined:
```
Prerendered modules
┌ ✦ src/demos/url/sources.prerender.ts
└ ✦ src/lib/docComponents.prerender.ts
2 prerendered modules inlined
```
The value is inlined wherever the module is imported. Inside a `mochi:hydrate` island it is therefore sent to the browser, so never export a secret from a prerendered module a hydrated component imports.
---
title: 'Architecture'
slug: architecture
ogTitle: 'How a Mochi app fits together'
description: 'The high-level model behind a Mochi app: server-first rendering, islands, programmatic routes, and the request lifecycle.'
---
## Architecture
This page describes the model behind a Mochi app — what happens on each request and how the pieces fit together. You do not need any of it to build an app. The feature docs cover every API directly.
### Server-first rendering
Mochi renders each page to HTML on the server. A page ships zero client JavaScript by default. You opt individual components into the browser by marking them as **islands** with a `mochi:*` directive. Everything outside an island stays static HTML.
See [Selective hydration](/docs/selective-hydration/), [Client-only components](/docs/client-only/), and [Server islands](/docs/server-islands/) for the directives.
### Programmatic routes
Routes are a plain `Record` passed to `Mochi.serve({ routes })`. Each key is a URL pattern. Each value comes from `Mochi.page`, `Mochi.api`, `Mochi.ws`, `Mochi.sse`, or `Mochi.file`. There is no file-based routing.
See [Defining routes](/docs/defining-routes/).
### The request lifecycle
1. A request matches a route pattern.
2. Your `handle` middleware runs in order, and can read or rewrite the request and the response.
3. The matched route produces a response — a rendered page, a JSON payload, a stream, or a file.
4. Hydratable islands and deferred server islands (`mochi:defer`) load afterwards, each on its own request.
See [Middleware](/docs/middleware/) and [Request context](/docs/request-context/).
### The Bun runtime
Mochi runs on Bun and builds on its standard library for the bundler, the HTTP and WebSocket server, and native SQLite and PostgreSQL.
See [Why Bun?](/docs/why-bun/).
---
title: 'CLI reference'
slug: cli
description: 'The mochi-framework command-line tool: build, generate-key, and update-skill.'
---
Installing `mochi-framework` puts a `mochi-framework` binary on your `PATH`. Inside a project it is available to `package.json` scripts directly. Anywhere else, run it with `bunx`:
```sh
bunx mochi-framework [options]
```
```sh
bunx mochi-framework --help # list every command
```
```sh
bunx mochi-framework --version # print the installed version
```
`-h`/`--help` and `-v`/`--version` work as shorthands.
## build
Produces a production bundle by reading config straight from your entry's `Mochi.serve()` call, so the prebuilt manifest stays single-sourced with the runtime. This is the command behind your `build` script:
```json
{
"scripts": {
"build": "mochi-framework build"
}
}
```
| Option | Default | Description |
| ----------------------- | ---------------- | ---------------------------------------------------------------------------------------------------- |
| `--entry ` | `./src/index.ts` | Runtime entry whose `Mochi.serve()` call supplies `routes`, `markdown`, `optimize`, and `publicDir`. |
| `--out-dir ` | `./.mochi` | Base build output directory. `--dev` builds nest under `/dev`. |
| `--public-dir ` | `./public` | Static assets directory. Scanned, never copied. |
| `--asset-prefix ` | `/_mochi` | URL prefix for framework client assets. |
| `--dev` | off | Build with `development: true`. |
The build lists every SSR entrypoint it compiled as one tree. Your routes come first, then three groups no route reaches: the [error page](/docs/error-handling/) (renders on a throw), [email templates](/docs/email/#svelte-templates) (render on a send), and [server islands](/docs/server-islands/) (render on a fetch of their own endpoint).
```
Route islands bundle
┌ ● / 1 2.45 kB
├ λ /health - -
├ ● /orders/:id 1 2.45 kB
│
│ Error page
├ ⚠ $mochi/templates/DefaultError.svelte 1 3.17 kB
│
│ Email template
├ ✉ src/emails/Receipt.svelte - 316 B
│
│ Server island
├ ◐ /_mochi/island/Cart_3iqkh56ovhduk 1 89.6 kB
└ ○ /_mochi/island/Stamp_dnp0teboqefy 0 235 B
● page with islands · λ api · ⚠ error page · ✉ email template · ◐ server island with islands · ○ server island
```
After the tree, the build lists the **resources** it emitted — one row per [local image import](/docs/images/#local-image-imports), largest first:
```
Resource dimensions size
┌ ▣ error-page-3jm1noc19vxtj.png 1400×807 330 kB
└ ▣ debug-bar-ycfe5vg1pwxv.png 1036×72 22.5 kB
2 assets · 353 kB
```
Silence the list with `Mochi.serve({ build: { resources: false } })`. The `build: done` summary still reports the asset count.
The manifest stores all artifact paths relative to the out-dir, so the output is relocatable. Move or copy it and boot with `outDir` (or `manifest`) pointing at the new location. See [Production builds](/docs/production-builds/#relocatable-builds).
Static files are the exception: the build reads `--public-dir` only to reject a file that shadows a route, and copies nothing. The runtime serves that directory from disk, so it travels with your source. A `0 public file(s)` summary for an app with static assets means `--public-dir` points somewhere unexpected.
## generate-key
Generates a `MOCHI_KEY` (a base64url-encoded 32-byte secret) and writes it to `.env` in the current directory:
```sh
bunx mochi-framework generate-key
```
It creates `.env` if missing, appends `MOCHI_KEY` if absent, and prompts before overwriting an existing key.
| Option | Description |
| --------------- | ---------------------------------------------------- |
| `-f`, `--force` | Overwrite an existing `MOCHI_KEY` without prompting. |
Set `MOCHI_KEY` for any deployment that runs more than one process or survives restarts. See [Server islands](/docs/server-islands/#encryption-key).
## update-skill
Fetches the latest `SKILL.md` — agent guidance for coding assistants — and writes it into your project for the given agent (default: `claude-code`):
```sh
bunx mochi-framework update-skill [agent]
```
Run it again whenever you upgrade the framework to keep the guidance in sync. See [Docs for LLMs](/docs/docs-for-llms/#agent-skill-recommended) for the full list of supported agents.
### Confirming an update
The hosted `SKILL.md` is instructions your coding agent will follow. When the file already exists and the fetched copy differs, the CLI prints a unified diff and asks before writing:
```sh
bunx mochi-framework update-skill
```
```
[mochi] Skill update fetched from https://mochi.fast/SKILL.md:
--- .claude/skills/mochi/SKILL.md
+++ https://mochi.fast/SKILL.md
@@ -12,3 +12,3 @@
Routes live in src/routes.ts.
-Use Mochi.page() for SSR pages.
+Use Mochi.page() for SSR pages and Mochi.api() for JSON.
[mochi] Apply this update?
```
| Option | Description |
| --------------- | ---------------------------------------------------------------------- |
| `-f`, `--force` | Accept the update without prompting. Required in non-interactive runs. |
A first write is not prompted — there is no prior content to review — and an unchanged file exits early with `is already up to date`.
Without `--force` and without a terminal, the command **exits 1** rather than answering the prompt for you. Scripts and CI must pass `--force` to accept whatever the endpoint serves.
---
title: 'Production builds'
slug: production-builds
description: 'How a Mochi build behaves in production: relocatable output and a persistent image cache.'
---
# Production builds
`mochi-framework build` writes a self-contained build to `.mochi/` (see the [CLI reference](/docs/cli/)). This page covers what that build gives you once it is deployed: it relocates cleanly from where you built it to where you run it, and its image cache can survive container restarts. For where to run it, see [Deployment options](/docs/deployment-options/); to containerize it, see [Building a Dockerfile](/docs/docker/).
## Relocatable builds
A manifest holds no absolute paths — artifacts are written relative to the out-dir, sources relative to the project root — so you can build in one place and run in another. Build in a CI stage, copy `.mochi/` into the final image, and point the runtime at wherever it landed:
```ts
Mochi.serve({ outDir: './.mochi' }); // default — or wherever you copied it
```
Paths resolve against the manifest's own directory, so pointing `manifest` at a relocated build works on its own:
```ts
Mochi.serve({ manifest: '/srv/app/build/manifest.json' });
```
Five things still anchor a prebuilt app to its project:
- **Build and serve from the same working directory.** Components are keyed relative to the project root, which both `mochi-framework build` and `Mochi.serve()` take to be the current working directory. Run both from the project root.
- **Ship your `public/` directory.** Static files are never copied into the build. The runtime scans `publicDir` (default `./public`) at startup in production exactly as in development. A deploy that ships only `.mochi/` and `src/` 404s every static file.
- **Keep the out-dir in the project tree.** The compiled SSR modules resolve `node_modules` from the out-dir's location.
- **On-demand server islands need sources.** Islands missing from the manifest are compiled at request time from source paths recorded at build. Prebuilt islands relocate fine.
- **Keep email templates in `src/emails/`.** A `Mochi.email({ component })` template is reachable only at send time, so the build walks that directory to find it. See [Svelte templates](/docs/email/#svelte-templates).
The manifest records a schema version, and the runtime loads only the exact version it writes. Booting a build made by a different `mochi-framework` version throws at startup. Always run `mochi-framework build` with the same version you serve with.
## Persistent image cache
Mochi's [image cache](/docs/images/) is written to disk under `cacheDir` (default `./.mochi/image-cache`). In a container that directory is recreated on every restart, so each redeploy starts with a cold cache and re-fetches and re-transforms every image. To keep the transformed bytes across restarts, point `cacheDir` at a dedicated path and mount a volume there:
```ts
Mochi.serve({
image: { cacheDir: process.env.MOCHI_IMAGE_CACHE_DIR /* , sizes: … */ },
});
```
```yaml
services:
site:
image: your-app
environment:
MOCHI_IMAGE_CACHE_DIR: /data/image-cache
volumes:
- image-cache:/data/image-cache
volumes:
image-cache:
```
A plain `docker run -v image-cache:/data/image-cache your-app` mounts the same volume. Keep the mount off `./.mochi` — its build cache is rebuilt on every boot and must not persist. If the container runs as a non-root user, pre-create the directory owned by that user in your `Dockerfile` so the mounted volume is writable.
Keep `MOCHI_KEY` stable across restarts. Image URLs are signed with a key derived from it, so a changed key invalidates already-minted links even though the cached bytes are still on disk.
---
title: 'Deployment options'
slug: deployment-options
description: 'Where to deploy your Mochi app: PaaS, VPS, big cloud, and self-hosted options.'
---
# Deployment options
Mochi is a **serverful** application, so it does not run on every serverless host. That is what gives Mochi its features: built-in SQLite, in-memory cache, WebSockets, and Server-Sent Events. You can build complex, data-driven realtime apps with no extra dependency and no external cloud services.
You can host Bun and Mochi at hundreds of hosts. Some popular options are below. For how a build behaves once it is deployed — relocatable output, a persistent image cache — see [Production builds](/docs/production-builds/).
None of the links below are affiliate links or endorsements.
## PaaS
Deploy code or containers. The platform manages infrastructure, scaling, and networking.
- 🇺🇸 Railway — dedicated Bun and Docker support
- 🇺🇸 Render — Docker-based web services, Git-push deploys
- 🇺🇸 Fly.io — Docker-native, global edge, scale-to-zero
- 🇺🇸 Heroku — supports Docker deployments
- 🇫🇷 Koyeb — Git or Docker, 250+ edge locations
- 🇫🇷 Clever Cloud — native Bun / Docker support
- 🇺🇸 Zeabur — auto-detects Bun
- 🇫🇷 Scaleway Serverless Containers — deploy from any registry, billed per millisecond
- 🇺🇸 DigitalOcean App Platform — Git or Docker deploy
## Traditional VPS / IaaS
You get a server, install Bun yourself, and manage the process (systemd, Docker).
- 🇩🇪 Hetzner — very cheap, popular with indie devs
- 🇺🇸 DigitalOcean Droplets — simple cloud VMs
- 🇫🇷 OVHcloud — dedicated servers, VPS, private cloud, with strong GDPR compliance
- 🇫🇷 Scaleway Instances — VMs alongside their serverless offering
- 🇺🇸 Vultr
- 🇺🇸 Akamai / Linode
- 🇺🇸 🇮🇱 Kamatera — pay-as-you-go cloud VMs
## Big cloud
Each offers VPS, serverless, containers, and Kubernetes. Pick the model that fits.
- 🇺🇸 AWS — EC2, Lambda + Web Adapter, Fargate, App Runner, ECS/EKS
- 🇺🇸 Google Cloud — Compute Engine, Cloud Run, GKE, Cloud Functions
- 🇺🇸 Azure — VMs, Container Apps, ACI, AKS
- 🇺🇸 Oracle Cloud — generous always-free ARM VMs
- 🇺🇸 IBM Cloud — VPC, Code Engine, IKS/OpenShift
## Self-hosted tools
Install these on a VPS from one of the providers above.
- Coolify — open-source, self-hosted PaaS
- Dokku — open-source mini-Heroku
- CapRover — open-source PaaS with web UI
## Hosted tools
Connect to your existing infrastructure at different cloud providers.
- 🇬🇧 Northflank — containers, jobs, and APIs, with bring-your-own-cloud
- 🇮🇳 Kuberns — Git-push deploy on AWS infra, no Dockerfile
- 🇺🇸 Convox
## Sub-path and static hosting
Mochi writes absolute URLs for everything it owns: `/_mochi/client/…`, `/_mochi/css/…`, the island `component-url`, and the `import` specifiers inside the client chunks. To host under a sub-path such as `https://example.com/my-app/`, bake the prefix in at build time:
```sh
mochi-framework build --asset-prefix /my-app/_mochi
```
The value lands in `manifest.json` and every emitted URL carries it. `Mochi.serve({ assetPrefix })` sets the same thing for on-demand compilation, but once a manifest exists its value wins and a differing `serve()` value only logs a warning.
`assetPrefix` covers framework assets only. Links you write yourself (`href="/about"`) and files in `public/` are served at the paths you give them, so prefix those in your own markup.
The same prefix is what makes a hand-rolled static export work: copy `.mochi/svelte-client` to `/my-app/_mochi/client` and `.mochi/svelte-css` to `/my-app/_mochi/css`, save each prerendered page, and nothing needs rewriting.
### Rewriting URLs yourself
If you post-process the HTML into page-relative URLs instead, treat `component-url` like any other attribute: it resolves against the page, so `../_mochi/client/…` on `/my-app/writing/` loads from `/my-app/_mochi/client/`, the same place a ` ` on that page does.
## Where these companies are based
The flag next to each provider above shows where the **company** is headquartered, not where its data centers are.
Provider headquarters & sources
| Provider | Country | Source |
| --- | --- | --- |
| Railway | 🇺🇸 USA | DPA (San Francisco, CA) |
| Render | 🇺🇸 USA | About (San Francisco, CA) |
| Fly.io | 🇺🇸 USA | Terms (San Francisco, CA) |
| Heroku | 🇺🇸 USA | About (San Francisco, CA) |
| Koyeb | 🇫🇷 France | Careers (Paris, France) |
| Clever Cloud | 🇫🇷 France | Legal notice (Nantes, France) |
| Zeabur | 🇺🇸 USA | About (Zeabur Inc., Delaware) |
| Scaleway | 🇫🇷 France | Legal notice (Paris, France) |
| DigitalOcean | 🇺🇸 USA | Terms (USA) |
| Hetzner | 🇩🇪 Germany | Imprint (Gunzenhausen, Germany) |
| OVHcloud | 🇫🇷 France | Terms (Roubaix, France) |
| Vultr | 🇺🇸 USA | Wikipedia (West Palm Beach, FL) |
| Akamai / Linode | 🇺🇸 USA | Company (Cambridge, MA) |
| Kamatera | 🇺🇸 USA · 🇮🇱 founded | Wikipedia (US HQ, Israeli-founded) |
| AWS | 🇺🇸 USA | About Amazon (Seattle, WA) |
| Google Cloud | 🇺🇸 USA | Wikipedia (Mountain View, CA) |
| Azure | 🇺🇸 USA | Microsoft facts (Redmond, WA) |
| Oracle Cloud | 🇺🇸 USA | Wikipedia (Austin, TX) |
| IBM Cloud | 🇺🇸 USA | Wikipedia (Armonk, NY) |
| Northflank | 🇬🇧 United Kingdom | About (London, UK) |
| Kuberns | 🇮🇳 India | LinkedIn (Gujarat, India) |
| Convox | 🇺🇸 USA | Y Combinator (Atlanta, GA) |
---
title: 'Building a Dockerfile'
slug: docker
description: 'A minimal production Dockerfile template for deploying Mochi apps with Bun.'
---
## Building a Dockerfile
A production Mochi app is a single Bun process. This image is a one-stage build on `oven/bun:1.4-alpine`.
### Minimal Dockerfile
```dockerfile
# file: Dockerfile
FROM oven/bun:1.4-alpine
WORKDIR /app
COPY . .
RUN bun install --production
RUN bun run build
EXPOSE 3333
CMD ["bun", "run", "start"]
```
Build and run:
```sh
docker build -t my-app .
docker run --rm -p 3333:3333 my-app
```
`bun run build` and `bun run start` must share a working directory. The template's single `WORKDIR /app` covers it. Watch a multi-stage build that copies `.mochi/` into a differently-shaped final image: keep the app at the same path in both stages, and copy `public/` across too — static files are read from that directory at runtime, never from `.mochi/`.
A one-route app lands at about 160 MB. About 87 MB is the Bun binary. The floor for any Bun-based image is around 105 MB.
### `.dockerignore`
Exclude artifacts and local state from the build context. They are regenerated inside the image:
```
# file: .dockerignore
node_modules
.mochi
.git
.env*
```
Do not add `public` to that list. Unlike `.mochi`, it is not regenerated inside the image — the runtime reads it from disk on every boot. If it does not make it in, the server warns at startup:
```
[mochi] publicDir "public" is missing or empty, but the build found 12 file(s) there —
every static file will 404.
```
### `--production` and devDeps
`bun install --production` omits everything under `devDependencies`. Keep `mochi-framework` and `svelte` in `dependencies`. Move `svelte-check`, `typescript`, and build-time scripts to `devDependencies`.
`bun run start` runs your `start` script (typically `bun src/index.ts`). The port your app listens on is the one passed to `Mochi.serve({ port })`. Match it with `EXPOSE` and `-p`.
---
title: 'Docs for LLMs'
slug: docs-for-llms
description: 'A remote MCP server, an agent skill, and an llms.txt index with concatenated bundles for LLM contexts.'
---
## LLM integrations
Mochi offers several ways to give an LLM up-to-date documentation. We recommend either the [**Agent skill**](#agent-skill-recommended) or the [**MCP server**](#mcp-server-recommended). You can also use the older [llms.txt format](#llmstxt).
### Agent skill (recommended)
Mochi publishes a `SKILL.md` that tells a coding assistant to fetch the relevant docs and demos from `/llms.txt` before writing framework code. Pull the latest copy into your project with the CLI:
```sh
bunx mochi-framework update-skill [agent]
```
This fetches `https://mochi.fast/SKILL.md` and writes it into your project. Run it again whenever you upgrade the framework.
The optional `agent` argument controls where the skill is written (default: `claude-code`):
| Agent | Destination |
| --------------------- | --------------------------------- |
| `claude-code` | `.claude/skills/mochi/SKILL.md` |
| `opencode` | `.opencode/skills/mochi/SKILL.md` |
| `antigravity` (`agy`) | `.agents/skills/mochi/SKILL.md` |
| `codex` | `.agents/skills/mochi/SKILL.md` |
### MCP Server (recommended)
Mochi runs an official remote MCP server at `https://mochi.fast/mcp` (HTTP transport). It exposes the same docs and demos as the skill — `get_documentation_sections` to list everything and `get_section` to read specific pages. Add it to your tool of choice below.
Run:
```sh
claude mcp add -t http -s project mochi https://mochi.fast/mcp
```
This adds the server at `project` scope. Pass `-s user` or `-s local` to change that.
1. Open **Settings > Connectors**
2. Click **Add Custom Connector**
3. Name it `mochi`
4. Set the remote MCP server URL to `https://mochi.fast/mcp`
5. Click **Add**
Add to `~/.codex/config.toml`:
```toml
experimental_use_rmcp_client = true
[mcp_servers.mochi]
url = "https://mochi.fast/mcp"
```
Run `/mcp add`, or edit `~/.copilot/mcp-config.json`:
```json
{ "mcpServers": { "mochi": { "url": "https://mochi.fast/mcp" } } }
```
Open the MCP store via the **"..."** dropdown at the top of the agent panel, click **Manage MCP Servers**, then **View raw config**, and add:
```json
{ "mcpServers": { "mochi": { "type": "http", "serverUrl": "https://mochi.fast/mcp" } } }
```
Edit `~/.gemini/config/mcp_config.json` and add:
```json
{ "mcpServers": { "mochi": { "type": "http", "serverUrl": "https://mochi.fast/mcp" } } }
```
Run `opencode mcp add`, choose **Remote**, name it `mochi`, and enter `https://mochi.fast/mcp`.
1. Open the command palette
2. Select **MCP: Add Server...**
3. Choose **HTTP (HTTP or Server-Sent-Events)**
4. Enter `https://mochi.fast/mcp` and press Enter
5. Name it `mochi`
6. Choose Global or Workspace scope
Open the command palette, select **View: Open MCP Settings**, click **Add custom MCP**, and add:
```json
{ "mcpServers": { "mochi": { "url": "https://mochi.fast/mcp" } } }
```
In your repo, go to **Settings > Copilot > Coding agent**, edit the MCP configuration, then save:
```json
{ "mcpServers": { "mochi": { "type": "http", "url": "https://mochi.fast/mcp", "tools": ["*"] } } }
```
Refer to your client's documentation for adding a remote MCP server and use `https://mochi.fast/mcp` as the URL.
### llms.txt
[`/llms.txt`](/llms.txt) is the index: a title, a one-line summary, and a linked list of every doc (`## Docs`), demo (`## Examples`), and blog post (`## Blog`), each pointing at its own plain-text file. The concatenated bundles below are linked under `## Optional`.
#### All docs concatenated
The full set of docs, concatenated in reading order, is served at [`/llms-recommended.txt`](/llms-recommended.txt). Use it when you want the model to have the complete API in one paste.
#### Docs + demo source
[`/llms-full.txt`](/llms-full.txt) includes everything in `/llms-recommended.txt` plus the source of every demo, every blog post, and the changelog. Use it when the model needs both the API and working examples.
#### Per-document text
Each doc is reachable as plain text at `/docs//llms.txt`, for example [`/docs/intro/llms.txt`](/docs/intro/llms.txt). The "Copy as llms.txt" button on each doc page emits just that page.
The changelog is served the same way at [`/docs/changelog/llms.txt`](/docs/changelog/llms.txt), and reads as a page at [`/docs/changelog/`](/docs/changelog/). Mochi fetches it from GitHub, so both return `503` (not `404`) when that fetch is unavailable.
#### Per-post text
Each published blog post is reachable as raw markdown at `/blog//llms.txt`, for example [`/blog/mochi-0-8-0/llms.txt`](/blog/mochi-0-8-0/llms.txt).
#### Per-demo source
Each demo's source is reachable as plain text alongside its demo page, usually `/demos//llms.txt`. It is the exact source `/llms-full.txt` bundles for that demo, scoped to one demo:
- [`/demos/hello-world/llms.txt`](/demos/hello-world/llms.txt)
- [`/demos/chat/llms.txt`](/demos/chat/llms.txt)
#### Machine-readable index
[`/llms.json`](/llms.json) returns a JSON index of every doc, blog post, and demo, each with its `title`, `description`, and an absolute `url` to its `llms.txt`.
```json
{
"docs": [{ "title": "Welcome", "description": "…", "url": "https://mochi.fast/docs/intro/llms.txt" }],
"posts": [{ "title": "Mochi 0.8.0", "description": "2026-07-21 — …", "url": "https://mochi.fast/blog/mochi-0-8-0/llms.txt" }],
"demos": [{ "title": "Hello World", "description": "…", "url": "https://mochi.fast/demos/hello-world/llms.txt" }]
}
```
---
title: 'Demos'
slug: demos
ogTitle: 'Example apps built with Mochi'
description: 'Production-style example apps demonstrating Mochi primitives like SSR, hydration, and real-time updates.'
---
## Demos
Production-style Mochi apps you can poke at live. Each one uses the same primitives the docs cover: SSR pages, hydrated islands, WebSocket / SSE routes, and form actions.
### Hacker News Clone
A full Hacker News reader: SSR pages, hydrated islands, a real API.
### Realtime Admin Panel
A live admin dashboard with WebSocket updates and server-driven state.
### Tailwind Todo App
A classic todo app styled with Tailwind CSS.
---
Built something with Mochi? Share it in the Mochi Discord . We are happy to feature it.
---
title: 'Mochi vs SvelteKit'
slug: mochi-vs-sveltekit
description: 'A side-by-side feature comparison of Mochi and SvelteKit, filterable by performance, backend, and frontend.'
---
## Mochi vs SvelteKit
Mochi and SvelteKit both render Svelte 5 on the server, and they make different bets. Mochi is SSR-first on Bun with islands-based selective hydration and a batteries-included backend (SQLite, queues, WebSockets, SSE, caching). SvelteKit is runtime-agnostic with a client-side router, broad deployment targets, and a mature ecosystem.
Use the filters below to focus on the areas you care about, or switch to **Mochi only** / **SvelteKit only** to see where each framework leads.
Migrating an existing app? The [Coming from SvelteKit](/docs/coming-from-sveltekit/) guide maps each SvelteKit concept to its Mochi equivalent.
# Demo Source Files
## Demo: api
### Api.svelte
```svelte
```
### ApiTester.svelte
```svelte
{#if loading}
Loading...
{:else if result}
{result}
{:else}
Click an endpoint to test it
{/if}
```
### routes.ts
```ts
import { Mochi, error } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/api': Mochi.page('./src/demos/api/Api.svelte'),
'/health': Mochi.api(({ method }) => Response.json({ status: 'ok', method })),
// curl -X POST http://localhost:3333/add -H 'Content-Type: application/json' -d '{"a": 2, "b": 3}'
'/add': Mochi.api(async ({ method, request }) => {
if (method !== 'POST') {
error(405, 'Method Not Allowed');
}
const { a, b } = (await request.json()) as { a: number; b: number };
return Response.json({ result: a + b });
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: cache-events
### CacheEvents.svelte
```svelte
Cached value
{time}
Status
{status}
Refresh under 3s for a fresh hit. Refresh between
3–10s
for stale + a background revalidate. After 10s the next refresh blocks on a fresh fetch (expired). Each event logs to your server
console.
```
### log.ts
```ts
import { MochiCache, mochiEvents, logger } from 'mochi-framework';
import { delay } from '../../components/sourceUtils';
export const slowClock = new MochiCache({
minTimeToStale: 3_000,
maxTimeToLive: 10_000,
});
// Tags each line so it's easy to grep alongside the framework's own logger output.
// setHandler (rather than .on) means dev re-imports don't pile up duplicate subscribers.
mochiEvents.setHandler('demo:cache-events:read', 'cache:read', ({ key, status }) => {
logger.info(`[demo:cache-events] read ${key} → ${status}`);
});
mochiEvents.setHandler('demo:cache-events:revalidate', 'cache:revalidate', ({ key }) => {
logger.info(`[demo:cache-events] revalidate ${key}`);
});
export async function getSlowTime() {
return slowClock.fetchWithStatus('slow-clock', async () => {
await delay(150);
return new Date().toISOString();
});
}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/cache-events': Mochi.page('./src/demos/cache-events/CacheEvents.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: captcha
### Captcha.svelte
```svelte
mintCaptcha() seals a single-use token at SSR. Sliding the handle advances a hash chain one link per step, then solves a proof-of-work over the final link — so the
challenge only exists once the slide has actually run, and never appears in the page.
verifyCaptcha() re-derives it server-side.
The submit button is not gated on the captcha here, so you can submit without solving it and watch the server reject you.
Submit twice (replay) verifies the same token twice in one action — the first call burns the nonce, so the second is a real replay. A failure carries a
reason, shown under each message: solve the captcha first and you get replay, which the demo answers with its own copy. Leave it unsolved and every
probe-able failure — tampered, too fast, expired, bad proof-of-work — collapses to rejected behind one generic message, so a bot can't tell them apart.
```
### CaptchaForm.svelte
```svelte
{#if sentMessage}
{:else}
{/if}
```
### routes.ts
```ts
import { Mochi, fail, success, mintCaptcha, verifyCaptcha } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/captcha': Mochi.page('./src/demos/captcha/Captcha.svelte', {
serverProps: () => ({ captcha: mintCaptcha() }),
actions: {
submit: async ({ formData }) => {
const captcha = await verifyCaptcha(formData);
if (!captcha.ok) {
return fail(400, { error: captcha.error, reason: captcha.reason });
}
const name = String(formData.get('name') ?? '').trim();
return success({ message: `Verified — nice to meet you, ${name || 'stranger'}.` });
},
// Verifies the token twice: the first call burns the nonce, so the second is a genuine replay.
// An unsolved token instead fails both calls and lands on 'rejected'.
replay: async ({ formData }) => {
await verifyCaptcha(formData);
const captcha = await verifyCaptcha(formData);
if (!captcha.ok) {
return fail(400, {
error: captcha.reason === 'replay' ? 'Our own copy: that token is spent — reload for a fresh challenge.' : captcha.error,
reason: captcha.reason,
});
}
return success({ message: 'Unexpected — the nonce survived a double verify.' });
},
},
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: captcha-styling
### CaptchaStyling.svelte
```svelte
Set the --mochi-captcha-* vars on any ancestor and they inherit down into the widget. The emoji and label props cover the rest. Each one below
is live — slide any of them.
```
### StylingDemo.svelte
```svelte
Defaults
No CSS at all. The widget ships light-mode defaults in each var() fallback, so it looks finished out of the box.
Themed
Map the vars onto your palette. These point at this site's tokens, so this one follows the light/dark toggle.
Candy
The vars are just colours — hand them anything, including gradients.
Terminal
Square off the corners with --mochi-captcha-radius for a different silhouette.
```
### themes.ts
```ts
// Each theme's emoji/labels/declarations feed both the live widget and the code
// samples rendered beside it, so what the page shows can't drift from what it does.
export const themes = {
themed: {
emoji: '🍡',
label: 'Slide the mochi to the right',
verifyingLabel: 'Steaming…',
verifiedLabel: 'Freshly made — thanks!',
css: `--mochi-captcha-accent: var(--accent);
--mochi-captcha-accent-soft: var(--accent-soft);
--mochi-captcha-accent-soft-text: var(--accent-soft-text);
--mochi-captcha-border: var(--border);
--mochi-captcha-track-bg: var(--surface-muted);
--mochi-captcha-handle-bg: var(--surface);
--mochi-captcha-hint-text: var(--text-subtle);`,
},
candy: {
emoji: '🍭',
label: 'Slide for a tasty treat',
verifyingLabel: 'Unwrapping…',
verifiedLabel: 'Enjoy your sweet!',
css: `--mochi-captcha-accent: #d6336c;
--mochi-captcha-accent-soft: linear-gradient(90deg, #ffd6e7, #ffa8cd);
--mochi-captcha-accent-soft-text: #a61e4d;
--mochi-captcha-border: #ffa8cd;
--mochi-captcha-track-bg: #fff0f6;
--mochi-captcha-handle-bg: #fff;
--mochi-captcha-hint-text: #c2255c;`,
},
terminal: {
emoji: '▶',
label: 'SLIDE TO PROVE HUMANITY',
verifyingLabel: 'VERIFYING…',
verifiedLabel: 'ACCESS GRANTED',
css: `--mochi-captcha-radius: 0;
--mochi-captcha-accent: #33ff77;
--mochi-captcha-accent-soft: #04301a;
--mochi-captcha-accent-soft-text: #33ff77;
--mochi-captcha-border: #33ff77;
--mochi-captcha-track-bg: #04150c;
--mochi-captcha-handle-bg: #04301a;
--mochi-captcha-hint-text: #2bbd5a;
font-family: var(--font-mono);`,
},
} as const;
export type ThemeName = keyof typeof themes;
// The values baked into each var()'s fallback in MochiCaptcha.svelte. Shown as
// the "Defaults" sample; deliberately not applied to anything.
export const defaultsSample = `/* Each var's built-in fallback. */
:root {
--mochi-captcha-accent: #4a7c59;
--mochi-captcha-accent-soft: #e0ebe1;
--mochi-captcha-accent-soft-text: #2f5b3f;
--mochi-captcha-border: #e8e4d8;
--mochi-captcha-track-bg: #faf8f1;
--mochi-captcha-handle-bg: #fffdf8;
--mochi-captcha-handle-text: var(--mochi-captcha-accent);
--mochi-captcha-hint-text: #6e756d;
--mochi-captcha-radius: 999px;
}`;
export const rule = (selector: string, declarations: string): string => `${selector} {\n${declarations.replace(/^/gm, ' ')}\n}`;
export const markup = (theme: (typeof themes)[ThemeName]): string =>
` `;
```
### routes.ts
```ts
import { Mochi, mintCaptcha } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/captcha-styling': Mochi.page('./src/demos/captcha-styling/CaptchaStyling.svelte', {
// One token per variant so each can be solved independently.
serverProps: () => ({ captchas: [mintCaptcha(), mintCaptcha(), mintCaptcha(), mintCaptcha()] }),
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: charts
### Charts.svelte
```svelte
Minimal setup: install it, import from layerchart, and Mochi bundles it into whichever island imports it. Full component reference at
layerchart.com .
Pure SSR — SVG
LayerChart can render to pure SVG or HTML by omitting Mochi's hydrate directives. That needs two opt-ins: ssr (LayerChart skips server rendering otherwise) and
explicit
width/height to set the dimensions, since nothing resizes the container client side.
SVG is recommended for pure SSR graphs: the <Svg> gets a matching viewBox, so the fixed 600×220 geometry rescales to any column width with
CSS alone.
It's composed from <Chart> primitives rather than the <BarChart> shortcut: that shortcut's marks snippet shadows its own
marks prop, which the Svelte server compiler turns into unbounded recursion once ssr forces a server render.
LayerChart's SSR pitfall. LayerChart doesn't officially support SSR for its SVG and HTML renderers, and its all-in-one chart components (<BarChart>,
<AreaChart>, <LineChart>, <PieChart>, <ScatterChart>, <ArcChart>) can't be
server-rendered — turning on ssr crashes the whole page. For a no-JavaScript chart, build it from the smaller pieces like this one does.
Theming — light/dark
LayerChart ships its tooltip, axis, and other default styles pre-coloured with CSS light-dark() so they read correctly in both modes out of the box — you lean on
that any time you use a LayerChart component without hand-styling its colours, the tooltip most of all. Bun downlevels light-dark() to a
--buncss-light/--buncss-dark toggle but only defines the pair beside a color-scheme rule in the same stylesheet, which LayerChart doesn't
ship — so define it once on :root in your shell:
Interactive — SVG
The same library imported from layerchart/svg, hydrated via mochi:hydrate — hover the plot for the crosshair and tooltip. Leaving ssr off
ships an empty frame; omitting
width (which would override the measured container) is what makes it responsive. The tooltip sets portal: false because LayerChart otherwise portals
it to <body>, escaping the themed frame and rendering unstyled.
Helpers are fine when hydrating. With ssr off, the marks never render on the server, so the all-in-one components like
<AreaChart>
work here — no need to compose from primitives the way the pure-SSR chart does.
Interactive — HTML
Imported from layerchart/html, so the bars are real DOM boxes instead of SVG. This one uses the <BarChart> shortcut — fine here because
ssr is off, so the marks never render on the server. Hydration measures the container, so it reflows fluidly with no viewBox trick needed.
Donut — SVG
A PieChart with a negative innerRadius and cRange colours; click a legend swatch to toggle a slice. Client-only is usually the better fit for
a chart: since LayerChart draws nothing server-side anyway, hydrating would server-render an empty container and reconcile it, whereas a client-only island skips the server pass
entirely and lets you ship real fallback markup — the skeleton below, wiped the moment the component mounts.
Loading chart…
Server-rendered image chart
You can server-render charts as an image (JPEG/PNG) using layerchart/server. This is possible today in Mochi but requires a bit of glue code code, see
serverChart.ts in the tab view at the bottom of the demo for an example. The general recommendation if you want to go SSR-only is to use the SVG approach in the first
example.
```
### ChartFrame.svelte
```svelte
{@render children()}
```
### StaticTrafficChart.svelte
```svelte
```
### TrafficChart.svelte
```svelte
`${v / 1000}k` },
// LayerChart portals the tooltip to by default, which would move it out of the
// frame that maps its theme custom properties and leave it unstyled.
tooltip: { root: { portal: false } },
}}
/>
```
### HtmlBars.svelte
```svelte
`${v / 1000}k` },
tooltip: { root: { portal: false } },
}}
/>
```
### RuntimeDonut.svelte
```svelte
```
### ServerTrafficChart.svelte
```svelte
`${v / 1000}k`} stroke="rgba(100,116,139,0.4)" fill="rgba(71,85,105,0.9)" />
```
### serverChart.ts
```ts
import path from 'node:path';
import { compile, compileModule } from 'svelte/compiler';
import { createCanvas, Path2D, GlobalFonts } from '@napi-rs/canvas';
import { fontFile } from '../../lib/fontFile.ts';
import { traffic } from './data.ts';
// renderChart draws the marks with Path2D and expects it on globalThis.
if (typeof globalThis.Path2D === 'undefined') {
(globalThis as { Path2D?: unknown }).Path2D = Path2D;
}
// layerchart's canvas renderer falls back to the `sans-serif` family, which Alpine (the
// production base image) can't resolve because it ships no fonts — so axis labels rasterize
// blank. Register the site's UI font under that family name so text renders everywhere.
GlobalFonts.registerFromPath(fontFile('@fontsource/public-sans', 'public-sans-latin-400-normal.woff2'), 'sans-serif');
export type ChartFormat = 'png' | 'jpeg';
type ChartBundle = {
renderChart: (
component: unknown,
options: { width: number; height: number; format: ChartFormat; background: string; props: object; createCanvas: (w: number, h: number) => unknown },
) => Uint8Array;
ServerTrafficChart: unknown;
};
// Mochi can't import `.svelte` in server code, so compile the ServerChart tree to a JS bundle here.
// `.svelte.js` rune modules (layerchart's chart state) need compileModule, not compile.
const svelteServerPlugin: import('bun').BunPlugin = {
name: 'svelte-server',
setup(build) {
build.onLoad({ filter: /\.svelte$/ }, async ({ path: f }) => ({
contents: compile(await Bun.file(f).text(), { generate: 'server', filename: f }).js.code,
loader: 'js',
}));
build.onLoad({ filter: /\.svelte\.[jt]s$/ }, async ({ path: f }) => ({
contents: compileModule(await Bun.file(f).text(), { generate: 'server', filename: f }).js.code,
loader: 'js',
}));
},
};
let bundle: Promise | undefined;
async function loadBundle(): Promise {
const result = await Bun.build({
entrypoints: [path.join(import.meta.dir, 'serverChartBundle.ts')],
plugins: [svelteServerPlugin],
target: 'bun',
conditions: ['svelte'],
external: ['svelte', 'svelte/*'],
outdir: path.join(import.meta.dir, '..', '..', '..', '.mochi', 'serverchart'),
throw: true,
});
return import(result.outputs[0]!.path) as Promise;
}
export async function renderTrafficChart(opts: { width: number; height: number; format: ChartFormat }): Promise> {
const { renderChart, ServerTrafficChart } = await (bundle ??= loadBundle());
const bytes = renderChart(ServerTrafficChart, {
width: opts.width,
height: opts.height,
format: opts.format,
background: 'white',
props: { data: traffic },
createCanvas,
});
// Copy into an ArrayBuffer-backed view so it satisfies Response's BodyInit.
return new Uint8Array(bytes);
}
```
### serverChartBundle.ts
```ts
// Bun.build entrypoint only — never imported at runtime. Bundling renderChart together with the
// chart means the compiled output carries no `.svelte` import, which Mochi's server runtime can't
// load. serverChart.ts compiles this with a Svelte plugin and imports the pure-JS result.
export { renderChart } from 'layerchart/server';
export { default as ServerTrafficChart } from './ServerTrafficChart.svelte';
```
### data.ts
```ts
export type TrafficPoint = { month: string; requests: number; cached: number };
export const traffic: TrafficPoint[] = [
{ month: 'Jan', requests: 1240, cached: 820 },
{ month: 'Feb', requests: 1380, cached: 940 },
{ month: 'Mar', requests: 1610, cached: 1170 },
{ month: 'Apr', requests: 1520, cached: 1090 },
{ month: 'May', requests: 1840, cached: 1390 },
{ month: 'Jun', requests: 2110, cached: 1660 },
{ month: 'Jul', requests: 2340, cached: 1880 },
{ month: 'Aug', requests: 2280, cached: 1810 },
{ month: 'Sep', requests: 2560, cached: 2040 },
{ month: 'Oct', requests: 2890, cached: 2350 },
{ month: 'Nov', requests: 3120, cached: 2580 },
{ month: 'Dec', requests: 3410, cached: 2870 },
];
export type RuntimeSlice = { stage: string; ms: number };
export const runtimes: RuntimeSlice[] = [
{ stage: 'Server render', ms: 42 },
{ stage: 'Island hydration', ms: 27 },
{ stage: 'Asset transfer', ms: 19 },
{ stage: 'Idle', ms: 12 },
];
// Passed straight into LayerChart's `color` / `cRange` props, which write them into `fill`
// and `stroke` attributes. Custom properties inherit into SVG, so they resolve against
// `.chart-frame` and follow the site's theme toggle without any JavaScript.
export const seriesColors = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)'];
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
const MIN_SIZE = 64;
const MAX_SIZE = 2000;
// The canvas is allocated in one shot from these, so an unclamped `?width=40000` is a memory-exhaustion lever.
function clampSize(raw: string | null, fallback: number): number {
// `?width=` yields '' and `Number('')` is 0, which would clamp to MIN_SIZE instead of the default.
const n = raw?.trim() ? Number(raw) : fallback;
if (!Number.isFinite(n)) {
return fallback;
}
return Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.trunc(n)));
}
export const routes: Record = {
'/demos/charts': Mochi.page('./src/demos/charts/Charts.svelte'),
'/demos/charts/traffic.png': Mochi.api(
async ({ url }) => {
const { renderTrafficChart } = await import('./serverChart');
const width = clampSize(url.searchParams.get('width'), 640);
const height = clampSize(url.searchParams.get('height'), 240);
const format = url.searchParams.get('format') === 'jpeg' ? 'jpeg' : 'png';
const image = await renderTrafficChart({ width, height, format });
return new Response(image, {
headers: { 'Content-Type': `image/${format}`, 'Cache-Control': 'public, max-age=3600' },
});
},
{ rateLimit: { limit: 60, window: '1m' } },
),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: chat
### Chat.svelte
```svelte
```
### ChatWidget.svelte
```svelte
{#each messages as msg, i (i)}
{msg.text}
{/each}
{#if messages.length === 0}
Send a message to get started
{/if}
{#if disconnected}
{disconnected} — reload the page to reconnect.
{:else if reconnecting}
Reconnecting…
{/if}
Send
```
### routes.ts
```ts
import { Mochi, getRequestContext, rateLimitMemoryStore } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const CHAT_MAX_MESSAGE_BYTES = 4 * 1024;
// Bun drops a frame past this before `message` runs, so it has to sit above the app limit or the handler's 1009 close is unreachable.
export const CHAT_WS_MAX_PAYLOAD_BYTES = 2 * CHAT_MAX_MESSAGE_BYTES;
export const CHAT_MAX_HISTORY_MESSAGES = 100;
export const CHAT_MAX_HISTORY_BYTES = 64 * 1024;
export const CHAT_RATE_LIMIT = 20;
export const CHAT_RATE_WINDOW_MS = 10_000;
interface HistoryEntry {
text: string;
bytes: number;
}
interface ChatSocketData {
rateKey: string;
}
// `getClientAddress()` only returns null when Bun has no peer address at all (behind a proxy it still yields the proxy's IP,
// so separating visitors there is `proxy.addressHeader`'s job); a per-socket key keeps that edge case from sharing one bucket.
export function rateKeyFor(address: string | null): string {
return address ? `ip:${address}` : `socket:${crypto.randomUUID()}`;
}
export function createChatRoutes(): Record {
const history: HistoryEntry[] = [];
let historyBytes = 0;
// Keyed by client address so the allowance survives a reconnect, which would otherwise hand out a fresh budget and replay the history buffer again.
const limiter = rateLimitMemoryStore();
return {
'/demos/chat': Mochi.page('./src/demos/chat/Chat.svelte'),
'/ws/chat': (() => {
const TOPIC = 'chat';
return Mochi.ws({
upgrade() {
return { rateKey: rateKeyFor(getRequestContext().getClientAddress()) };
},
open(ws) {
ws.subscribe(TOPIC);
for (const entry of history) {
ws.send(entry.text);
}
},
async message(ws, message) {
const bytes = typeof message === 'string' ? Buffer.byteLength(message, 'utf8') : message.byteLength;
if (bytes > CHAT_MAX_MESSAGE_BYTES) {
ws.close(1009, 'Message too large');
return;
}
if (typeof message !== 'string') {
ws.close(1003, 'Chat frames must be text');
return;
}
const { count } = await limiter.hit(ws.data.user.rateKey, CHAT_RATE_WINDOW_MS, CHAT_RATE_LIMIT);
if (count > CHAT_RATE_LIMIT) {
ws.close(1008, 'Message rate exceeded');
return;
}
history.push({ text: message, bytes });
historyBytes += bytes;
while (history.length > CHAT_MAX_HISTORY_MESSAGES || historyBytes > CHAT_MAX_HISTORY_BYTES) {
historyBytes -= history.shift()!.bytes;
}
ws.publish(TOPIC, message);
ws.send(message);
},
close(ws) {
ws.unsubscribe(TOPIC);
},
});
})(),
};
}
export const routes = createChatRoutes();
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: client-only
### ClientOnly.svelte
```svelte
Mounting in the browser…
The animation runs on requestAnimationFrame with a live fps counter, scaled to window.devicePixelRatio — browser APIs read at the top of the component's
script. This paragraph, by contrast, sits outside the island — it ships with the SSR HTML and doesn't move when the component mounts.
Scroll down — the island below is marked mochi:clientOnly:visible, so it stays a fallback until it reaches the viewport, then mounts in the browser.
Lazy client-only with mochi:clientOnly:visible
Mounts when scrolled into view…
Same browser-only mount as above, but deferred: an IntersectionObserver holds off the mount() until the placeholder enters the viewport, and the component's
bundle and CSS load only then. Open the console to see it mount as you scroll.
```
### BrowserCanvas.svelte
```svelte
```
### MountClock.svelte
```svelte
{label}: mounted on scroll {seconds}s ago.
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/client-only': Mochi.page('./src/demos/client-only/ClientOnly.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: cookie-vary-test
### CookieVaryTest.svelte
```svelte
Check the response headers: Vary: Cookie is set by the route handle.
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { Handle, MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/cookie-vary-test': Mochi.page('./src/demos/cookie-vary-test/CookieVaryTest.svelte'),
};
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event);
if (event.url.pathname === '/cookie-vary-test' || event.url.pathname === '/cookie-vary-test/') {
response.headers.set('Vary', 'Cookie');
}
return response;
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: cookies
### Cookies.svelte
```svelte
```
### CookieDemo.svelte
```svelte
SSR Read (server)
These values were read from the Cookie header during SSR:
mochi_username {ssrUsername}
mochi_theme {ssrTheme}
Client Read (browser)
Live values from cookies.get() on the {isServer ? 'server' : 'client'}:
mochi_username
{cookies.get('mochi_username') ?? '(not set)'}
mochi_theme
{cookies.get('mochi_theme') ?? '(not set)'}
Set Cookies
Username
Theme
(not set)
Light
Dark
Auto
Set via Client
Set via API
Clear
{#if message}
{message}
{/if}
```
### routes.ts
```ts
import { Mochi, error, getRequestContext } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/cookies': Mochi.page('./src/demos/cookies/Cookies.svelte'),
'/api/cookie': Mochi.api(async ({ method, request }) => {
if (method !== 'POST') {
error(405, 'Method Not Allowed');
}
const { username, theme } = (await request.json()) as {
username: string;
theme: string;
};
const { cookies } = getRequestContext();
cookies.set('mochi_username', username, { path: '/', maxAge: 604800 });
cookies.set('mochi_theme', theme, { path: '/', maxAge: 604800 });
return Response.json({ ok: true });
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: cron
### Cron.svelte
```svelte
```
### CronLog.svelte
```svelte
{status}
{#each entries as entry (entry.seq)}
#{entry.seq}
{fmtTime(entry.at)}
demo-activity-log ran
{/each}
{#each skeletons as i (i)}
{/each}
```
### cron.server.ts
```ts
import { Mochi } from 'mochi-framework';
import type { CronLogEntry } from './types';
const MAX_ENTRIES = 100;
// The cron appends one entry per minute forever; trimming to the last MAX_ENTRIES keeps the in-memory log bounded.
const log: CronLogEntry[] = [];
let seq = 0;
type WsClient = { send: (data: string) => void };
const clients = new Set();
function broadcast(message: string): void {
for (const ws of clients) {
ws.send(message);
}
}
// The site rides the default `cronStorage: 'memory'`, so the demo writes no file into the working dir; set
// `Mochi.serve({ cronStorage })` to make the schedule durable across restarts and multiple nodes.
export const activityLog = Mochi.cron('demo-activity-log', '* * * * *', (run) => {
const entry: CronLogEntry = { seq: ++seq, at: Date.now(), scheduledTime: run.scheduledTime };
log.push(entry);
if (log.length > MAX_ENTRIES) {
log.splice(0, log.length - MAX_ENTRIES);
}
broadcast(JSON.stringify({ type: 'entry', entry }));
});
export function addClient(ws: WsClient): void {
clients.add(ws);
ws.send(JSON.stringify({ type: 'snapshot', entries: [...log].reverse() }));
}
export function removeClient(ws: WsClient): void {
clients.delete(ws);
}
```
### types.ts
```ts
// Components must import types from here, NOT from cron.server.ts: a type import from a side-effectful server
// module still drags that module into the SSR component bundle, re-registering its cron job a second time.
export interface CronLogEntry {
seq: number;
/** Epoch ms at which the cron handler ran. */
at: number;
/** Epoch ms at which the scheduler claimed the firing, which is earlier than `at` by the queue pickup delay. */
scheduledTime: number;
}
export type CronLogMessage = { type: 'snapshot'; entries: CronLogEntry[] } | { type: 'entry'; entry: CronLogEntry };
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue, MochiCronConfig } from 'mochi-framework';
import { activityLog, addClient, removeClient } from './cron.server';
// Mounted in the site's Mochi.serve({ cron }) call — see src/routes.ts.
export const cron: MochiCronConfig[] = [activityLog];
export const routes: Record = {
'/demos/cron': Mochi.page('./src/demos/cron/Cron.svelte'),
'/ws/cron-log': Mochi.ws({
open(ws) {
addClient(ws);
},
message() {},
close(ws) {
removeClient(ws);
},
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes, cron } from './routes';
await Mochi.serve({
port: 3333,
routes,
cron,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: custom-transitions
### PageOne.svelte
```svelte
```
### PageTwo.svelte
```svelte
```
### SpinCard.svelte
```svelte
```
### shared.ts
```ts
// Demo plumbing shared by PageOne/PageTwo so the description and source-tab
// list aren't duplicated. Hidden from the displayed demo source by
// stripDemoWrapper, like the sources.prerender.ts import it replaces.
export const description =
'Bring your own animation to with custom={{ in, out }} — raw @keyframes bodies that drive the page you leave and the page you land on. Here the card does a funky 3D spin on every navigation.';
export { sources } from './sources.prerender.ts';
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/custom-transitions': Mochi.page('./src/demos/custom-transitions/PageOne.svelte'),
'/demos/custom-transitions/two': Mochi.page('./src/demos/custom-transitions/PageTwo.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: data-loading
### DataLoading.svelte
```svelte
{#if pokemon}
{:else}
No Pokémon found for "{id}".
{/if}
```
### PokemonSelector.svelte
```svelte
```
### PokemonHeader.svelte
```svelte
{isServer ? 'SERVER' : 'BROWSER'} | {isDev ? 'DEV' : 'PROD'}
```
### PokemonMeta.svelte
```svelte
```
### PokemonStats.svelte
```svelte
```
### routes.ts
```ts
import { Mochi, fail, redirect } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/data-loading': (req: Request) => Response.redirect(new URL('/demos/data-loading/pikachu', req.url), 302),
'/demos/data-loading/:id': Mochi.page('./src/demos/data-loading/DataLoading.svelte', {
actions: {
default: async ({ formData }) => {
const pokemon = String(formData.get('pokemon') ?? '')
.trim()
.toLowerCase();
if (!pokemon) {
return fail(400, { error: 'Pokemon required' });
}
return redirect(303, `/demos/data-loading/${encodeURIComponent(pokemon)}`);
},
},
}),
};
```
### cache.ts
```ts
import { MochiCache } from 'mochi-framework';
export const pokemonCache = new MochiCache({
minTimeToStale: 14_400_000,
maxTimeToLive: 86_400_000,
});
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: defer-invalidation
### DeferInvalidation.svelte
```svelte
Loading
Error modes
A reload can fail in two different places, and they are not the same thing: the render can throw while the island is being built on the server, or the request for that HTML can
fail. Only the second counts as a failed reload.
Loading
Loading
```
### Controls.svelte
```svelte
reloadDeferredIsland('1')}>Reload 1
reloadDeferredIsland('2-and-3')}>Reload 2 + 3
reloadDeferredIslandAll()}>Reload all
Island 1
{one.count} reloads · {stamp(one)}
Islands 2 + 3
{pair.count} reloads · {stamp(pair)}
```
### ServerClock.svelte
```svelte
{label}
rendered {renderedAt}
```
### LiveCounter.svelte
```svelte
{label} hydrated
rendered {renderedAt}
count++}>Clicked {count} {count === 1 ? 'time' : 'times'}
```
### ErrorModes.svelte
```svelte
reloadDeferredIsland('flaky')}>Reload 4 — make the render throw
Island 4 throws about half the time. Its own <svelte:boundary> catches it, so the island degrades to its failed snippet and the rest of the page is
untouched. The fetch itself was fine, so lastReloadOk stays true .
Island 4: {rendered.count} reloads · {stamp(rendered)}
Reload 5 — make the fetch fail
This one breaks the request instead of the render. Nothing comes back to swap in, so the island keeps the content it already had and
lastReloadOk flips to false . Note the timestamp on island 5 does not change.
Island 5: {offline.count} reloads · {stamp(offline)}
```
### FlakyPanel.svelte
```svelte
{#snippet failed(error)}
{label}
failed — {error.message}
{/snippet}
```
### FlakyContent.svelte
```svelte
{label}
rendered {renderedAt}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/defer-invalidation': Mochi.page('./src/demos/defer-invalidation/DeferInvalidation.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: email
### Email.svelte
```svelte
Pick one of the pre-written emails and send it. Then open the
dev email outbox to read the captured message — the styled HTML body, plain-text part, recipients, and headers.
With {'{@attach enhance(...)}'}
Plain HTML
Sending an attachment
Pass attachments to Mochi.email({ ... }) to send a file alongside the body. The route action reads a small pre-resized image off disk and
attaches it; the outbox lists it as a 📎 chip on the captured message.
```
### EmailForm.svelte
```svelte
{#if sentSubject}
{:else}
{/if}
```
### AttachmentForm.svelte
```svelte
{#if sentFilename}
{:else}
{/if}
```
### emails/PresetEmail.svelte
```svelte
🍡 Mochi
{#if preset === 'welcome'}
Welcome aboard, {name}!
Thanks for signing up. Mochi renders Svelte on the server and hydrates only the islands that need it — so your pages stay fast by default.
Read the docs
Glad to have you. Reply any time — a real human reads these.
{:else if preset === 'receipt'}
Thanks for your order, {name}
Here's a copy of your receipt for order #1024 .
{#each receiptItems as item (item.label)}
{item.label}
{item.amount}
{/each}
Total
$120.00
Charged to the card ending in 4242. Questions? Just reply.
{:else}
Reset your password
Hi {name}, we received a request to reset the password on your Mochi account.
Choose a new password
This link expires in 30 minutes. If you didn't ask for this, you can safely ignore this email.
{/if}
```
### emails/AttachmentEmail.svelte
```svelte
🍡 Mochi
A photo for you, {name}
Here's a mochi we thought you'd like — it's attached as {filename} .
Open the attachment to take a look. Reply any time.
```
### presets.ts
```ts
// The recipient is a fixed server-side constant — the demo never lets a visitor
// type an address, so no mail can be aimed at an arbitrary inbox.
export const DEMO_TO = 'Ada Lovelace ';
export interface EmailPreset {
id: string;
label: string;
subject: string;
blurb: string;
}
export const EMAIL_PRESETS: EmailPreset[] = [
{
id: 'welcome',
label: 'Welcome email',
subject: 'Welcome to Mochi 🍡',
blurb: 'A friendly onboarding note with a call-to-action button.',
},
{
id: 'receipt',
label: 'Order receipt',
subject: 'Your receipt #1024',
blurb: 'A transactional receipt with a small line-item table.',
},
{
id: 'reset',
label: 'Password reset',
subject: 'Reset your password',
blurb: 'A security email with a time-limited reset link.',
},
];
export const presetById = (id: string): EmailPreset | undefined => EMAIL_PRESETS.find((p) => p.id === id);
// `path` resolves server-side in the route action; the client only needs `filename` and `previewUrl`.
export const ATTACHMENT = {
subject: 'A photo for you 🍡',
filename: 'mochi.jpg',
path: './src/demos/email/mochi-photo.jpg',
previewUrl: '/demos/email/mochi-photo.jpg',
contentType: 'image/jpeg',
} as const;
```
### routes.ts
```ts
import { Mochi, fail, success } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
import { ATTACHMENT, DEMO_TO, presetById } from './presets';
export const routes: Record = {
'/demos/email': Mochi.page('./src/demos/email/Email.svelte', {
actions: {
// The form only submits a `preset` id; validating it against the allowlist
// means no visitor-supplied recipient/subject/body can ever reach the mailer.
send: async ({ formData }) => {
const preset = presetById(String(formData.get('preset') ?? ''));
if (!preset) {
return fail(400, { error: 'Pick one of the pre-written emails.' });
}
await Mochi.email({
from: 'Mochi Demo ',
to: DEMO_TO,
subject: preset.subject,
component: './src/emails/PresetEmail.svelte',
props: { preset: preset.id, name: 'Ada' },
});
return success({ preset: preset.id, subject: preset.subject });
},
// The recipient, subject, and file are all fixed server-side — nothing about the attachment comes from the request.
sendPhoto: async () => {
const content = await Bun.file(ATTACHMENT.path).bytes();
await Mochi.email({
from: 'Mochi Demo ',
to: DEMO_TO,
subject: ATTACHMENT.subject,
component: './src/emails/AttachmentEmail.svelte',
props: { name: 'Ada', filename: ATTACHMENT.filename },
attachments: [{ filename: ATTACHMENT.filename, content, contentType: ATTACHMENT.contentType }],
});
return success({ filename: ATTACHMENT.filename });
},
},
}),
// Serves the small demo image so the attachment form can preview it.
'/demos/email/mochi-photo.jpg': Mochi.file(ATTACHMENT.path),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: entity-props
### EntityDemo.svelte
```svelte
The parent (server-only) passes literal HTML entities in string="…" attributes on a mochi:hydrate island. Svelte decodes them for the SSR render,
and Mochi's preprocessor decodes the same value into the serialized props the client hydrates with — so the two never disagree. Watch the
Rendered on line flip from server to client after hydration while every value stays the same.
```
### EntityIsland.svelte
```svelte
Rendered on: {isBrowser ? 'client (hydrated)' : 'server (SSR)'}
Prop
Authored attribute
Value received
{#each rows as row (row.name)}
{row.name}
{row.authored}
{row.value}
{/each}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/entity-props': Mochi.page('./src/demos/entity-props/EntityDemo.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: error
### ErrorDemo.svelte
```svelte
Mochi catches any throw from a page's serverProps resolver or Svelte <script>, plus any request that doesn't match a route, and renders the
built-in default error page. Pass your own errorPage to Mochi.serve() to replace it. The handleError hook runs for every error — use it
to log, forward to an error tracker, sanitize the message, or return a Response to short-circuit rendering.
Try it:
/demos/error/500
the page's <script> throws during SSR
/demos/error/404
the serverProps resolver calls error(404, ...)
/does-not-exist
no route matches — handleError fires with error: null, then the error page renders with status 404
/demos/error/redirect
the page throws, but handleError returns Response.redirect(...) — you land back on this page instead of seeing the error component
A custom error component receives a single error prop with status,
message, and (in development only) stack — typed as
MochiErrorProps.
This site's handleError:
Watch your dev server output while clicking the links above — each visit logs one
handleError: line via logger from mochi-framework. Unmatched routes log error null; SSR throws log
error present.
```
### Error500.svelte
```svelte
You should never see this — the page throws during SSR.
```
### routes.ts
```ts
import { Mochi, error } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/error': Mochi.page('./src/demos/error/ErrorDemo.svelte'),
'/demos/error/500': Mochi.page('./src/demos/error/Error500.svelte'),
'/demos/error/404': Mochi.page('./src/demos/error/Error500.svelte', {
serverProps: () => {
error(404, 'This item does not exist.');
},
}),
// Throws during SSR, but the site-wide handleError returns a redirect Response
// for this pathname, so the error page is never rendered.
'/demos/error/redirect': Mochi.page('./src/demos/error/Error500.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: error-boundaries
### ErrorBoundaries.svelte
```svelte
{#snippet caughtFallback(error: unknown)}
Caught by user-written boundary:
{error instanceof Error ? error.message : String(error)}
{/snippet}
3. mochi:hydrate — client throw
SSR is fine, but the script throws synchronously once the island tries to hydrate. The defensive try/catch around hydrate() catches it and swaps the island for the
failure stub — the rest of the page (already rendered on the server) is untouched.
4. mochi:defer — server island throw
Server islands render at a separate endpoint. When that render throws, the endpoint returns a 200 with an error stub — the browser doesn't waste retries on a deterministic
failure. The user-supplied loading children stay until the response arrives.
Loading from server…
5. mochi:defer — healthy island, inner mochi:hydrate client throw
The server island itself renders successfully — it's the inner mochi:hydrate
child that throws once it tries to hydrate on the client. The child's auto-boundary catches its own failure, so the rest of the server island content stays intact.
Loading from server…
```
### ThrowOnSsr.svelte
```svelte
On the server this island threw, so the boundary swapped in the failure stub — you briefly see it flash before hydration takes over, or if you disable JavaScript. Then the client
re-renders the component cleanly and replaces the stub with this content. This works but is not recommended.
```
### ThrowOnClient.svelte
```svelte
This island renders fine on the server. Once the client tries to hydrate, the script throws synchronously — the boundary catches it and swaps in the failure stub.
```
### ThrowOnServerIsland.svelte
```svelte
never rendered
```
### HealthyServerIsland.svelte
```svelte
The server island itself rendered successfully.
Inside it sits a mochi:hydrate child that throws on the client — its boundary catches the failure independently:
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/error-boundaries': Mochi.page('./src/demos/error-boundaries/ErrorBoundaries.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: file
### File.svelte
```svelte
```
### FileViewer.svelte
```svelte
{#each routes as r (r.path)}
load(r.path)}>
{r.label}
{r.path}
{/each}
{loading ? 'Loading…' : meta || 'Pick a route to fetch the file.'}
{#if body}
{body}
{/if}
Download sample.txt ↓
```
### routes.ts
```ts
import { Mochi, error } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
// Only these names map to a fixture on disk; anything else 404s from the
// resolver itself rather than probing the filesystem.
const ALLOWED = new Set(['sample', 'notes']);
export const routes: Record = {
'/demos/file': Mochi.page('./src/demos/file/File.svelte'),
// String form — serve one fixed file from disk.
'/demos/file/download': Mochi.file('./src/demos/file/sample.txt'),
// Resolver form — pick the file per request from the route param.
'/demos/file/dynamic/:name': Mochi.file((_req, params) => {
const name = params.name ?? '';
if (!ALLOWED.has(name)) {
error(404, `No file named "${name}"`);
}
return `./src/demos/file/${name}.txt`;
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: file-upload
### FileUploadDemo.svelte
```svelte
With {'{@attach enhance(...)}'}
Plain HTML
```
### FileUpload.svelte
```svelte
{label}
{#if errorMessage}
{errorMessage}
{/if}
{#if fileResult}
{fileResult.filename} — {fileResult.size} bytes
{fileResult.content}
{/if}
```
### routes.ts
```ts
import { Mochi, fail, success } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/file-upload': Mochi.page('./src/demos/file-upload/FileUploadDemo.svelte', {
actions: {
uploadFile: async ({ formData }) => {
const file = formData.get('file');
if (!(file instanceof File) || file.size === 0) {
return fail(400, { error: 'No file selected' });
}
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
if (ext !== 'txt' && ext !== 'md') {
return fail(400, { error: 'Only .txt and .md files are accepted' });
}
if (file.size > 100 * 1024) {
return fail(400, { error: 'File too large (max 100 KB)' });
}
const content = await file.text();
return success({ filename: file.name, content, size: file.size });
},
},
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: font-loading
### FontLoading.svelte
```svelte
Fontsource package example
Add @fontsource/jetbrains-mono and import it.
The quick brown fox jumps over the lazy dog. 1234567890
Standalone .woff2 example
Drop a .woff2 next to your component and reference it from a tiny
@font-face CSS file:
Import the CSS (import './lobster.css'). Mochi serves the .woff2 as a separate content-hashed file — small fonts (≤4 kB) stay inlined in the bundled
CSS as data URIs.
The quick brown fox jumps over the lazy dog. 1234567890
```
### lobster.css
```ts
@font-face {
font-family: 'Lobster';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./lobster.woff2') format('woff2');
}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/font-loading': Mochi.page('./src/demos/font-loading/FontLoading.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: form-cancel
### FormCancel.svelte
```svelte
The action sleeps 3 s to simulate a slow lookup. Each variant below uses the same action but cancels at a different point — or not at all.
cancel() — pre-flight short-circuit
The submit callback receives cancel. Calling it skips the fetch entirely — no request is sent, no result callback runs. Use this for client-side
validation or any condition you can check before leaving the browser.
controller.abort() — in-flight cancellation
The submit callback also receives an
AbortController. Calling controller.abort()
stops a fetch that has already started. Here a 1.5 s timeout aborts the 3 s lookup; enhance swallows the AbortError and resets the form to idle.
Plain HTML — no enhance
Without JavaScript there is nothing to cancel. The browser POSTs and waits the full 3 s, then the page re-renders with the result.
```
### CancelDemo.svelte
```svelte
{label}
{#if result}
{result}
{/if}
{#if message}
{message}
{/if}
```
### AbortDemo.svelte
```svelte
{label}
{#if result}
{result}
{/if}
{#if message}
{message}
{/if}
```
### PlainDemo.svelte
```svelte
{label}
{#if initialResult}
{initialResult}
{/if}
{#if initialMessage}
{initialMessage}
{/if}
```
### routes.ts
```ts
import { Mochi, fail, success } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/form-cancel': Mochi.page('./src/demos/form-cancel/FormCancel.svelte', {
actions: {
lookup: async ({ formData }) => {
const query = String(formData.get('query') ?? '').trim();
if (!query) {
return fail(400, { error: 'Query is required' });
}
await Bun.sleep(3000);
return success({ result: `Found: ${query} — status active` });
},
},
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: form-errors
### FormErrors.svelte
```svelte
The action throws a plain Error. With {'{@attach enhance(...)}'} the error message is shown inline. Without it, the server renders the Mochi error page.
With {'{@attach enhance(...)}'}
Plain HTML
```
### ErrorDemo.svelte
```svelte
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/form-errors': Mochi.page('./src/demos/form-errors/FormErrors.svelte', {
actions: {
throwError: () => {
throw new Error('Something went wrong on the server');
},
},
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: form-redirects
### FormRedirects.svelte
```svelte
, …) from an action. With {@attach enhance(...)}, the JSON envelope is intercepted before navigating. Without it, the browser follows the native 303 response.`}
{sources}
>
{#if redirected}
Redirected here via HTTP 303 (non-enhanced path).
{/if}
The action returns redirect(303, …). With {'{@attach enhance(...)}'} the JSON envelope is intercepted so you can inspect it before navigating. Without it,
the browser follows the HTTP 303.
With {'{@attach enhance(...)}'}
Plain HTML
```
### RedirectDemo.svelte
```svelte
```
### routes.ts
```ts
import { Mochi, redirect, getRequestContext } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/form-redirects': Mochi.page('./src/demos/form-redirects/FormRedirects.svelte', {
serverProps: () => {
const { url } = getRequestContext();
return { redirected: url.searchParams.has('redirected') };
},
actions: {
doRedirect: () => redirect(303, '/demos/form-redirects?redirected=1'),
},
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: form-return-data
### FormReturnData.svelte
```svelte
A minimal action that returns a random number via success({'{ value }'}). The hydrated version updates the input reactively; the non-hydrated version re-renders
the whole page and the component reads the value from getRequestContext().form.
With {'{@attach enhance(...)}'}
Plain HTML
```
### RandomRoll.svelte
```svelte
```
### routes.ts
```ts
import { Mochi, success } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/form-return-data': Mochi.page('./src/demos/form-return-data/FormReturnData.svelte', {
actions: {
random: () => success({ value: Math.floor(Math.random() * 100) + 1 }),
},
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: hello-world
### HelloWorld.svelte
```svelte
Hello, world!
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/hello-world': Mochi.page('./src/demos/hello-world/HelloWorld.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: hydratable
### Hydratable.svelte
```svelte
Page (SSR-only)
Runs in the page's top-level script and is inlined into the rendered HTML.
SQLite {fact.sqliteVersion} , server time {fact.computedAt}
Island using hydratable()
Reads the SSR value out of window.__svelte.h on the client without re-running the work.
Island using a prop
Receives the value as a prop; Mochi serialises it into the island's HTML.
```
### FactCard.svelte
```svelte
SQLite {fact.sqliteVersion} , server time {fact.computedAt}
clicks++}>Clicked {clicks} times
```
### FactCardProps.svelte
```svelte
SQLite {fact.sqliteVersion} , server time {fact.computedAt}
clicks++}>Clicked {clicks} times
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/hydratable': Mochi.page('./src/demos/hydratable/Hydratable.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: hydration
### Hydration.svelte
```svelte
mochi:defer
Server island — the component is not in the initial page. A placeholder ships, then the browser fetches the rendered HTML on demand. Pair with hydrated child components
to also hydrate parts of the island after it loads.
Loading from server…
```
### HydrationTarget.svelte
```svelte
rendered at {renderedAt}
count++}>
Clicked {count}
{count === 1 ? 'time' : 'times'}
{#if !isBrowser}
This button won't work since the component is not hydrated!
{/if}
{@render children?.()}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/hydration': Mochi.page('./src/demos/hydration/Hydration.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: image
### ImageDemo.svelte
```svelte
Component
A plain <Image> references a named size (declared in image.sizes) and renders a single <img> with no client JS. Minting is synchronous
— no image is fetched or resized during SSR:
Local image imports
Import a local image Vite-style and get back {'{ src, width, height, format }'}. Pass the object straight to <Image> (transforms and
placeholder work as usual), or drop hero.src into a plain <img>. The file is served from a content-hashed URL and read from disk
for transforms — no network fetch:
A bare <Image src={'{hero}'}> with no size renders the original at its intrinsic dimensions ({hero.width}×{hero.height}), straight from the static URL:
Or use hero.src directly — it's a real URL ({hero.src}):
With a blur placeholder
Add placeholder to show a ThumbHash blur behind the image — it's the <img>'s own background-image, so no client JS is needed. The
blur is computed in the background on first use (never blocking SSR), so it appears from the second render onward:
{#if blur}
{:else}
{/if}
Inside a hydrated island
<Image> also works inside a mochi:hydrate island: the server-minted URL is serialized into the page (via Svelte's
hydratable) and reused during hydration, so the browser never needs the encryption secret. The button is live client-side state:
Props passed to a hydrated island — like this card's src — are serialized in plain text into the page for hydration, so the source URL is visible to the client
here. If your origin must stay secret, keep <Image> in server-rendered markup or a server island, whose props are encrypted.
Programmatic
getImageUrl(src, 'square') returns the same encrypted URL you can use anywhere:
{directUrl}
The square size uses fit: 'inside', which preserves aspect ratio and fits within the 400×400 box — so this 3:2 photo becomes
400×267. Set fit: 'fill' on the size to force an exact square (stretching); Bun.Image has no crop/cover mode.
Full-size original
getImageUrl(src) with no size name returns a URL for the un-resized original — fetched once and shared, so every variant above reuses this one cached download:
{originalUrl}
Gallery
Fourteen source photos, each rendered through the square size with a placeholder blur-up — all server-rendered, zero client JS:
{#each gallery as src, i (src)}
{/each}
```
### ImageIslandCard.svelte
```svelte
likes++}>
{likes}
{likes === 1 ? 'like' : 'likes'}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/image': Mochi.page('./src/demos/image/ImageDemo.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
// Named image sizes — the URL only carries the src + size name, and the endpoint runs the transform lazily on request.
image: {
sizes: {
hero: { width: 600, height: 400, fit: 'inside' },
square: { width: 400, height: 400, fit: 'inside' },
card: { width: 400, height: 267, fit: 'inside' },
thumb: { width: 240, height: 240, fit: 'inside' },
'fit-fill': { width: 240, height: 240, fit: 'fill' },
'fit-inside': { width: 240, height: 240, fit: 'inside' },
rotate90: { width: 200, height: 200, fit: 'inside', rotate: 90 },
rotate180: { width: 200, height: 200, fit: 'inside', rotate: 180 },
rotate270: { width: 200, height: 200, fit: 'inside', rotate: 270 },
flip: { width: 200, height: 200, fit: 'inside', flip: true },
flop: { width: 200, height: 200, fit: 'inside', flop: true },
grayscale: { width: 200, height: 200, fit: 'inside', modulate: { saturation: 0 } },
brighten: { width: 200, height: 200, fit: 'inside', modulate: { brightness: 1.5 } },
saturate: { width: 200, height: 200, fit: 'inside', modulate: { saturation: 2 } },
'fmt-jpeg': { width: 300, height: 300, fit: 'inside', format: 'jpeg', quality: 85 },
'fmt-png': { width: 300, height: 300, fit: 'inside', format: 'png' },
'fmt-webp': { width: 300, height: 300, fit: 'inside', format: 'webp', quality: 80 },
},
},
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: image-events
### ImageEvents.svelte
```svelte
Rendering this <Image> on a cold cache fetches the original, encodes a resized variant, and computes a ThumbHash placeholder — three
image:store events, one per file written to disk:
Each event carries the on-disk path plus kind, src, size, and (for variants) dimensions/format — everything an S3 mirror
needs to upload the byte file. image:delete fires later, when the background janitor sweep evicts stale entries or you call
invalidateImage().
```
### log.ts
```ts
import { mochiEvents, logger } from 'mochi-framework';
// A few source photos the page resizes on cold load, minting original + variant
// (+ placeholder) cache writes — each fires an `image:store` we log below.
export const remote = 'https://sta-public.fra1.cdn.digitaloceanspaces.com/mochi/mochi-3.jpg';
// This is exactly where an S3 mirror would read `path` and PUT/DELETE the object.
// `setHandler` (not `.on`) keeps dev re-imports from piling up duplicate subscribers.
mochiEvents.setHandler('demo:image-events:store', 'image:store', ({ kind, path, size, width, height }) => {
const dims = width && height ? ` ${width}x${height}` : '';
logger.info(`[demo:image-events] store ${kind}${dims} (${size}B) → ${path}`);
});
mochiEvents.setHandler('demo:image-events:delete', 'image:delete', ({ kind, reason, path, size }) => {
logger.info(`[demo:image-events] delete ${kind} (${reason}, freed ${size}B) → ${path}`);
});
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/image-events': Mochi.page('./src/demos/image-events/ImageEvents.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
// Named image sizes — the URL only carries the src + size name, and the endpoint runs the transform lazily on request.
image: {
sizes: {
hero: { width: 600, height: 400, fit: 'inside' },
square: { width: 400, height: 400, fit: 'inside' },
card: { width: 400, height: 267, fit: 'inside' },
thumb: { width: 240, height: 240, fit: 'inside' },
'fit-fill': { width: 240, height: 240, fit: 'fill' },
'fit-inside': { width: 240, height: 240, fit: 'inside' },
rotate90: { width: 200, height: 200, fit: 'inside', rotate: 90 },
rotate180: { width: 200, height: 200, fit: 'inside', rotate: 180 },
rotate270: { width: 200, height: 200, fit: 'inside', rotate: 270 },
flip: { width: 200, height: 200, fit: 'inside', flip: true },
flop: { width: 200, height: 200, fit: 'inside', flop: true },
grayscale: { width: 200, height: 200, fit: 'inside', modulate: { saturation: 0 } },
brighten: { width: 200, height: 200, fit: 'inside', modulate: { brightness: 1.5 } },
saturate: { width: 200, height: 200, fit: 'inside', modulate: { saturation: 2 } },
'fmt-jpeg': { width: 300, height: 300, fit: 'inside', format: 'jpeg', quality: 85 },
'fmt-png': { width: 300, height: 300, fit: 'inside', format: 'png' },
'fmt-webp': { width: 300, height: 300, fit: 'inside', format: 'webp', quality: 80 },
},
},
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: image-invalidation
### ImageInvalidationDemo.svelte
```svelte
The images below all derive from one shared cached original , fetched from our own
/demos/image-invalidation/source.jpg route — a Mochi.file() endpoint that serves a random bundled photo on every request. Hit the button to
invalidateImage(src, { hard: true }): it hard-deletes the cached original and cascades to every named size, so the next request
re-fetches the source and all sizes update to the same new photo in lockstep .
{#if generation > 0}
Invalidated {generation} time{generation === 1 ? '' : 's'}
{/if}
Shared original
Named sizes off that original
Shown at their relative sizes on one row, so the difference is visible at a glance:
{#each variants as v (v.size)}
{v.label}
{/each}
invalidateImage() clears the cache on the server . A browser already holding a copy (via Cache-Control in production) keeps showing it until
that lapses — so this demo appends a small &g= nonce to force an immediate re-request. Pass { hard: false } (the default) instead to mark
the original stale: the next request serves the cached bytes right away and re-fetches in the background.
```
### routes.ts
```ts
import { Mochi, invalidateImage, redirect, getRequestContext } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
// Our own source endpoint serves a *random* one of the bundled photos on each
// request, so an invalidated cache is visibly different once it's re-fetched.
const IMAGE_COUNT = 14;
const pickImage = () => `./images/mochi-${1 + Math.floor(Math.random() * IMAGE_COUNT)}.jpg`;
// The image source is our own endpoint, absolute so the image endpoint can fetch
// it. `.jpg` keeps it clear of the site's trailingSlash normalization.
const sourceUrl = () => `${getRequestContext().url.origin}/demos/image-invalidation/source.jpg`;
let generation = 0;
export const routes: Record = {
'/demos/image-invalidation/source.jpg': Mochi.file(pickImage),
'/demos/image-invalidation': Mochi.page('./src/demos/image-invalidation/ImageInvalidationDemo.svelte', {
serverProps: () => ({ src: sourceUrl(), generation }),
actions: {
// Post/Redirect/Get so a refresh doesn't re-submit; the bumped `generation` rides through serverProps on the GET.
default: async () => {
await invalidateImage(sourceUrl(), { hard: true });
generation++;
return redirect(303, '/demos/image-invalidation/');
},
},
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
// Named image sizes — the URL only carries the src + size name, and the endpoint runs the transform lazily on request.
image: {
sizes: {
hero: { width: 600, height: 400, fit: 'inside' },
square: { width: 400, height: 400, fit: 'inside' },
card: { width: 400, height: 267, fit: 'inside' },
thumb: { width: 240, height: 240, fit: 'inside' },
'fit-fill': { width: 240, height: 240, fit: 'fill' },
'fit-inside': { width: 240, height: 240, fit: 'inside' },
rotate90: { width: 200, height: 200, fit: 'inside', rotate: 90 },
rotate180: { width: 200, height: 200, fit: 'inside', rotate: 180 },
rotate270: { width: 200, height: 200, fit: 'inside', rotate: 270 },
flip: { width: 200, height: 200, fit: 'inside', flip: true },
flop: { width: 200, height: 200, fit: 'inside', flop: true },
grayscale: { width: 200, height: 200, fit: 'inside', modulate: { saturation: 0 } },
brighten: { width: 200, height: 200, fit: 'inside', modulate: { brightness: 1.5 } },
saturate: { width: 200, height: 200, fit: 'inside', modulate: { saturation: 2 } },
'fmt-jpeg': { width: 300, height: 300, fit: 'inside', format: 'jpeg', quality: 85 },
'fmt-png': { width: 300, height: 300, fit: 'inside', format: 'png' },
'fmt-webp': { width: 300, height: 300, fit: 'inside', format: 'webp', quality: 80 },
},
},
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: image-pipeline
### ImagePipelineDemo.svelte
```svelte
Each example references a size by name. getImageUrl is synchronous — it only signs a URL; the fetch, decode, and transform happen in the endpoint on the browser's
request, cached on disk so later requests skip the work. getImage runs the same size inline when you need the bytes server-side.
Metadata
getImage() returns the transformed bytes plus dimensions and format:
{meta.width} × {meta.height} · {meta.format}
Resize · fit
The same source into a 240×240 box. fit: 'fill' stretches to the exact box; fit: 'inside' preserves aspect ratio and fits within it:
fit: 'fill'
fit: 'inside'
Rotate
A size's rotate turns the image clockwise in multiples of 90:
rotate: 90
rotate: 180
rotate: 270
Flip · flop
flip: true mirrors vertically (about the x-axis); flop: true mirrors horizontally:
Modulate
A size's modulate adjusts brightness and saturation (1 = unchanged):
saturation: 0
brightness: 1.5
saturation: 2
Output formats
The same 300px image through three format sizes, with byte sizes from getImage(). Bun.Image can also encode avif:
{#each formats as { label, url, size } (label)}
{label} · {kb(size)}
{/each}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/image-pipeline': Mochi.page('./src/demos/image-pipeline/ImagePipelineDemo.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
// Named image sizes — the URL only carries the src + size name, and the endpoint runs the transform lazily on request.
image: {
sizes: {
hero: { width: 600, height: 400, fit: 'inside' },
square: { width: 400, height: 400, fit: 'inside' },
card: { width: 400, height: 267, fit: 'inside' },
thumb: { width: 240, height: 240, fit: 'inside' },
'fit-fill': { width: 240, height: 240, fit: 'fill' },
'fit-inside': { width: 240, height: 240, fit: 'inside' },
rotate90: { width: 200, height: 200, fit: 'inside', rotate: 90 },
rotate180: { width: 200, height: 200, fit: 'inside', rotate: 180 },
rotate270: { width: 200, height: 200, fit: 'inside', rotate: 270 },
flip: { width: 200, height: 200, fit: 'inside', flip: true },
flop: { width: 200, height: 200, fit: 'inside', flop: true },
grayscale: { width: 200, height: 200, fit: 'inside', modulate: { saturation: 0 } },
brighten: { width: 200, height: 200, fit: 'inside', modulate: { brightness: 1.5 } },
saturate: { width: 200, height: 200, fit: 'inside', modulate: { saturation: 2 } },
'fmt-jpeg': { width: 300, height: 300, fit: 'inside', format: 'jpeg', quality: 85 },
'fmt-png': { width: 300, height: 300, fit: 'inside', format: 'png' },
'fmt-webp': { width: 300, height: 300, fit: 'inside', format: 'webp', quality: 80 },
},
},
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: is-hydratable
### IsHydratable.svelte
```svelte
```
### HydrationProbe.svelte
```svelte
Depth {depth}
isHydratable()
{String(hydratable)}
{#if depth < max}
{/if}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/is-hydratable': Mochi.page('./src/demos/is-hydratable/IsHydratable.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: island-depth
### IslandDepth.svelte
```svelte
Each level is delayed on purpose — and twice as long in the opted-out chain, so the loading pattern is easy to follow. The opted-out chain fetches level by level; the inlined
chain makes one fetch that takes roughly the sum of its delays.
Opted out — waterfall {'{{ inline: false }}'}
Loading level 1
Inlined — one fetch {'{{ inline: true }}'}
Loading levels 1–4
Failure modes
A child that throws during its inline attempt degrades to a placeholder and fetches on its own — the parent's content is never lost. AlwaysThrows fails its fetch too and
renders the failure stub; ThrowsWhenInlined only throws while being inlined, so its own fetch succeeds and the content still arrives.
Loading FailDeep
```
### DepthLevel1.svelte
```svelte
Level 1 — server island (mochi:defer). It nests one more:
Loading level 2
```
### DepthLevel2.svelte
```svelte
Level 2 — server island (mochi:defer). It nests one more:
Loading level 3
```
### DepthLevel3.svelte
```svelte
Level 3 — server island (mochi:defer). It nests one more:
Loading level 4
```
### DepthLevel4.svelte
```svelte
Level 4 — deepest server island (mochi:defer). No more nesting below.
```
### FailDeep.svelte
```svelte
FailDeep — this parent island's content stays intact while both children fail their inline attempt:
Waiting for AlwaysThrows
Waiting for ThrowsWhenInlined
```
### AlwaysThrows.svelte
```svelte
{boom()}
```
### ThrowsWhenInlined.svelte
```svelte
ThrowsWhenInlined — threw during the inline attempt, degraded to a placeholder, and its own fetch succeeded.
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/island-depth': Mochi.page('./src/demos/island-depth/IslandDepth.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: island-props
### ServerRenderedParent.svelte
```svelte
ServerRenderedParent.svelte
Runs only on the server. No mochi:hydrate directive, so this component ships zero JavaScript — its output is inert HTML. It builds a props bag covering every
type devalue supports and hands it off to the child below. The Server type column in the child is captured here, before serialization; the
Client type column is resolved after hydration. Matching values prove the round-trip preserved each type.
props
▼
```
### ClientRenderedChild.svelte
```svelte
Prop
Value
Server type
Client type
{#each rows as row (row.label)}
{row.label}
{display(row.value)}
{serverTypes[row.label] ?? '—'}
{isBrowser ? typeOf(row.value) : '—'}
{/each}
Repeated ref
{repeatedRef[0] === repeatedRef[1] ? 'same ref' : 'different refs'}
identity check
{isBrowser ? 'identity check' : '—'}
Cyclic ref
{cyclicRef.self === cyclicRef ? 'self === obj' : 'broken'}
identity check
{isBrowser ? 'identity check' : '—'}
```
### devalueTypeOf.ts
```ts
export function typeOf(v: unknown): string {
if (v === undefined) {
return 'undefined';
}
if (v === null) {
return 'null';
}
if (typeof v === 'bigint') {
return 'BigInt';
}
if (typeof v === 'number') {
if (Number.isNaN(v)) {
return 'NaN';
}
if (v === Infinity) {
return 'Infinity';
}
if (Object.is(v, -0)) {
return '-0';
}
}
if (v instanceof Date) {
return 'Date';
}
if (v instanceof RegExp) {
return 'RegExp';
}
if (v instanceof Map) {
return 'Map';
}
if (v instanceof Set) {
return 'Set';
}
if (v instanceof URL) {
return 'URL';
}
if (v instanceof URLSearchParams) {
return 'URLSearchParams';
}
if (v instanceof Uint8Array) {
return 'Uint8Array';
}
if (ArrayBuffer.isView(v)) {
return (v as { constructor: { name: string } }).constructor.name;
}
if (Array.isArray(v)) {
return 'Array';
}
return typeof v;
}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/island-props': Mochi.page('./src/demos/island-props/ServerRenderedParent.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: lazy
### Lazy.svelte
```svelte
{#each Array(6) as _, i (i)}
Lazy Island #{i + 1}
{/each}
```
### LazyDemo.svelte
```svelte
#{index}
{#if isBrowser}
Hydrated! {rand}
{:else}
Waiting to hydrate...
{/if}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/lazy': Mochi.page('./src/demos/lazy/Lazy.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: lazy-server-island
### LazyServerIsland.svelte
```svelte
Scroll down to trigger each fetch. The IntersectionObserver fires per-island.
Island #1 — rootMargin: '200px'
Loading server island
{#each [2, 3, 4, 5] as i (i)}
Island #{i}
Loading server island
{/each}
```
### LazyServerDemo.svelte
```svelte
#{index}
Fetched at {renderedAt}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/lazy-server-island': Mochi.page('./src/demos/lazy-server-island/LazyServerIsland.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: login
### Login.svelte
```svelte
With {'{@attach enhance(...)}'}
Plain HTML
```
### EnhancedLoginForm.svelte
```svelte
{#if currentUser}
Signed in as {currentUser} .{hydratable ? ' No page reloads happened on the way here.' : ''}
{:else}
{/if}
```
### session.ts
```ts
import { createHmac, timingSafeEqual } from 'node:crypto';
import { getMochiConfig } from 'mochi-framework';
const CONTEXT = 'mochi-demo-session';
const DEFAULT_MAX_AGE_SEC = 7 * 24 * 60 * 60;
function sign(data: string): string {
const { secretKey } = getMochiConfig();
return createHmac('sha256', secretKey).update(CONTEXT).update(':').update(data).digest().subarray(0, 16).toString('base64url');
}
export interface SessionData {
username: string;
exp: number;
}
export function createSessionToken(username: string, maxAgeSec: number = DEFAULT_MAX_AGE_SEC): { token: string; maxAgeSec: number; exp: number } {
const exp = Math.floor(Date.now() / 1000) + maxAgeSec;
const payload = Buffer.from(JSON.stringify({ username, exp })).toString('base64url');
return { token: `${payload}.${sign(payload)}`, maxAgeSec, exp };
}
export function verifySessionToken(token: string | undefined): SessionData | null {
if (!token) {
return null;
}
const dot = token.lastIndexOf('.');
if (dot === -1) {
return null;
}
const payload = token.slice(0, dot);
const sig = token.slice(dot + 1);
const expected = sign(payload);
if (sig.length !== expected.length) {
return null;
}
try {
if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return null;
}
} catch {
return null;
}
let parsed: SessionData;
try {
parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
} catch {
return null;
}
if (typeof parsed.username !== 'string' || typeof parsed.exp !== 'number') {
return null;
}
if (parsed.exp < Math.floor(Date.now() / 1000)) {
return null;
}
return parsed;
}
```
### routes.ts
```ts
import { Mochi, fail, redirect, success, getRequestContext } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
import { createSessionToken, verifySessionToken } from './session';
const SESSION_COOKIE = 'mochi_login_session';
export const routes: Record = {
'/demos/login': Mochi.page('./src/demos/login/Login.svelte', {
serverProps: () => {
const { cookies } = getRequestContext();
const session = verifySessionToken(cookies.get(SESSION_COOKIE));
return { currentUser: session?.username ?? null };
},
actions: {
default: async ({ formData, cookies }) => {
const username = String(formData.get('username') ?? '').trim();
const password = String(formData.get('password') ?? '');
if (!username) {
return fail(400, { error: 'Username required', username });
}
if (password !== 'hunter2') {
return fail(401, { error: 'Bad credentials', username });
}
const { token, maxAgeSec } = createSessionToken(username);
cookies.set(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'Lax',
maxAge: maxAgeSec,
});
return success({ username });
},
logout: async ({ cookies }) => {
cookies.delete(SESSION_COOKIE, { path: '/' });
return redirect(303, '/demos/login');
},
},
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: mdsvex
### Greeting.md
```svelte
## Hello, {name}!
This block was authored in **Markdown** but compiled into a Svelte component
via [`mdsvex`](https://mdsvex.pngwn.io/), so prose, _emphasis_, `inline code`,
and a normal Svelte `
### A `.svx` file
Same compile pipeline as `.md` — mdsvex accepts both extensions. This snippet
shows a fenced code block (run through the framework's syntax highlighter)
plus a Svelte `{#each}` loop driven by a prop:
```ts
const items = ['Hydration', 'CSS imports', 'MdSvex'];
```
{#each items as item}
{item}
{/each}
```
### MdsvexDemo.svelte
```svelte
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/mdsvex': Mochi.page('./src/demos/mdsvex/MdsvexDemo.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: mode-watcher
### ModeWatcher.svelte
```svelte
mode-watcher needs a little setup: you mount its <ModeWatcher /> component once, and it reads localStorage and applies the mode
to the global <html> element — adding a
dark class and setting color-scheme. This site drives its own theme off a
data-theme attribute, so the two coexist without collision.
Mount it once, then any button can drive the mode:
Full API at mode-watcher.dev .
Toggle & set the mode
toggleMode, setMode, and resetMode drive the global theme; the
mode, userPrefersMode, and systemPrefersMode runes report the current state.
SSR-friendly theme, no flash
mode-watcher only persists to localStorage, which the server can't read — so on its own the first paint can flash. The bar above fixes that: the island mirrors
the resolved mode into a cookie, serverProps
reads it back into <ModeWatcher defaultMode>, and a transformPage hook stamps
class="dark" onto <html> before the response is sent. Pick a mode, then reload — the page comes back correct with no flicker (server sent:
shows the value the server used).
"system" can't be server-resolved without JS, so the cookie stores the resolved
light/dark.
```
### ModeControls.svelte
```svelte
toggleMode
flip the global <html> between light and dark
{#if mode.current}
{mode.current}
{:else}
undefined (server)
{/if}
Toggle mode
setMode / resetMode
user pick vs resolved vs OS
setMode('light')}>Light
setMode('dark')}>Dark
System
userPrefersMode
{userPrefersMode.current}
mode (resolved)
{mode.current ?? '—'}
systemPrefersMode
{systemPrefersMode.current ?? '—'}
```
### routes.ts
```ts
import { Mochi, getRequestContext } from 'mochi-framework';
import type { MochiRouteValue, Handle } from 'mochi-framework';
import { THEME_COOKIE } from './constants';
export const routes: Record = {
'/demos/mode-watcher': Mochi.page('./src/demos/mode-watcher/ModeWatcher.svelte', {
serverProps: () => ({ initialMode: getRequestContext().cookies.get(THEME_COOKIE) ?? null }),
}),
};
// SSR the resolved theme: read the cookie the island mirrors the mode into, and set the `dark`
// class on before it's sent so the first paint already matches — no flash on reload.
export const handle: Handle = async ({ event, resolve }) => {
if (!event.url.pathname.startsWith('/demos/mode-watcher')) {
return resolve(event);
}
if (getRequestContext().cookies.get(THEME_COOKIE) !== 'dark') {
return resolve(event);
}
return resolve(event, {
transformPage: ({ html }) => html.replace('', ''),
});
};
```
### constants.ts
```ts
// Shared by routes.ts (server read) and ModeControls.svelte (client write) so the name can't drift.
export const THEME_COOKIE = 'mochi-demo-theme';
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: nested-components
### NestedComponents.svelte
```svelte
mochi:hydrate on the root
One island wraps the whole five-level tree. Mochi serializes the root and its descendants once, ships a single bundle, and Svelte hydrates the entire subtree together.
mochi:defer + mochi:hydrate
The same recursive tree, rendered lazily as a server island. The placeholder ships with the page; the browser fetches the rendered HTML on demand. Because mochi:hydrate is also set, Svelte takes over once the fetched markup lands.
Loading nested tree from server
```
### Level.svelte
```svelte
count++}>L{depth} clicked {count} {count === 1 ? 'time' : 'times'}
{#if depth < max}
{/if}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/nested-components': Mochi.page('./src/demos/nested-components/NestedComponents.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: nested-islands
### NestedIslands.svelte
```svelte
The islands below are delayed on purpose to show the loading state.
1. mochi:defer server island with mochi:hydrate components inside
Loading server island
2. mochi:defer server island nesting two more islands
Loading server island
```
### DeferWithHydrators.svelte
```svelte
Server island (mochi:defer), rendered at {renderedAt}.
It contains three independently hydrated counters — click them:
```
### Counter.svelte
```svelte
count++}>{label}: {count}
```
### DeferNest.svelte
```svelte
Outer server island (mochi:defer). It nests two more islands:
Loading inner server island
Loading hydrated server island
```
### InnerServer.svelte
```svelte
{label} (mochi:defer)
Random number from the server: {randomNumber}
$props.id(): {islandId}
```
### HydratedServer.svelte
```svelte
{label} (mochi:defer mochi:hydrate)
$props.id(): {islandId}
count++}>Hydrated, clicked {count} times
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/nested-islands': Mochi.page('./src/demos/nested-islands/NestedIslands.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: portable-text
### PortableTextDemo.svelte
```svelte
Portable Text stores rich text as an array of JSON blocks instead of HTML, so the same content can render to a web page, a PDF or a native app. @portabletext/svelte
is the official Svelte 5 renderer for it; the format itself is documented at
portabletext.org . Everything on this page is server-rendered by Mochi except the last section.
Install
The same renderer, hydrated and not
Both panels below are the same component rendered from the same JSON. The left one is plain SSR markup; the right one carries mochi:hydrate, so the renderer ships
to the browser and re-runs on every keystroke.
Hydration is all-or-nothing per island: the right panel ships @portabletext/svelte plus every component it references, and its props are serialized in plain text into
the page. The left panel ships nothing.
```
### blocks.ts
```ts
import type { InputValue } from '@portabletext/svelte';
// Every block and span carries an explicit _key: the renderer mints Math.random() keys for
// missing ones, which would differ between the server render and the hydrated one.
export const playground = [
{
_type: 'block',
_key: 'pg1',
style: 'h3',
children: [{ _type: 'span', _key: 'pg1s1', text: 'A rendered heading' }],
},
{
_type: 'block',
_key: 'pg2',
style: 'normal',
children: [
{ _type: 'span', _key: 'pg2s1', text: 'A paragraph with a ' },
{ _type: 'span', _key: 'pg2s2', text: 'highlighted', marks: ['highlight'] },
{ _type: 'span', _key: 'pg2s3', text: ' word.' },
],
},
{ _type: 'callout', _key: 'pg3', text: 'And a custom callout block.' },
] satisfies InputValue;
export const playgroundJson = JSON.stringify(playground, null, 2);
```
### CalloutBlock.svelte
```svelte
{#if isInline}
{value.text}
{:else}
{value.text}
{#if value.tone}
{value.tone}
{/if}
{/if}
```
### Highlight.svelte
```svelte
{@render children()}
```
### Playground.svelte
```svelte
{live ? 'mochi:hydrate' : 'SSR only'}
{live ? 'edit the JSON — the output re-renders' : 'no JavaScript shipped — the field is read-only'}
Portable Text JSON
{#if parsed.error}
{parsed.error}
{/if}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/portable-text': Mochi.page('./src/demos/portable-text/PortableTextDemo.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: prop-dedup
### PropDedup.svelte
```svelte
Each group below renders the same component three times with the same props. In the SSR: you'll find three
<script type="application/json">
blocks (one per group) and nine <mochi-hydratable-island props-ref="…"> tags pointing at them. Each shared block carries a data-shared marker; a lone
island (like the source code viewer on this page) still gets its own block, just without that marker.
{#each groups as group (group.heading)}
{/each}
```
### SharedPropsCard.svelte
```svelte
{label}
{#each items as item (item.id)}
{item.text}
{/each}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/prop-dedup': Mochi.page('./src/demos/prop-dedup/PropDedup.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: props-id
### PropsId.svelte
```svelte
Static component (no hydration)
$props.id() works with zero JavaScript shipped — the id is minted during SSR, no hydration required.
Two hydrated islands, two ids
Each instance gets its own id, so the label/for pairs never collide. Hydration reuses the server-generated value — the id you see was minted during SSR.
Server island
Deferred islands render in a separate request; Mochi namespaces their ids with the island's own id (via render's idPrefix) so they cannot collide with ids already on the page.
Server island that also hydrates
With mochi:defer mochi:hydrate the namespaced id is read back from the SSR markers when the fragment hydrates — click the button and the id stays exactly the same, proving
the value survived from the deferred render into the hydrated client.
Two client-only islands, two ids
Client-only islands are never server-rendered, so each $props.id() is minted in the browser at mount. Svelte draws these from a global counter — unique even across
separate
mount() calls — so two independently-mounted islands still get distinct ids (e.g. c1 and c2) without any SSR pass to keep them apart.
```
### Field.svelte
```svelte
{@render children?.(uid)}
$props.id(): {uid}
```
### LabeledField.svelte
```svelte
{#snippet children(uid)}
Name
document.getElementById(`${uid}-name`)?.focus()}>Focus input via its id
{/snippet}
```
### ServerStamp.svelte
```svelte
Rendered on demand in a separate request 🏝️
```
### ServerHydratedStamp.svelte
```svelte
Rendered on the server, then hydrated 🏝️⚡
clicks++}>Clicked {clicks} times
```
### ClientStamp.svelte
```svelte
Never server-rendered — the id is minted in the browser at mount 🏝️🖥️
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/props-id': Mochi.page('./src/demos/props-id/PropsId.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: protection
### Protection.svelte
```svelte
This very page is protected — the interstitial you just passed (or breezed through on a warm clearance) came from
Mochi.serve({ protection }). The first visit answers 403 with a verification page instead of the demo; a hidden
MochiCaptchaAuto island runs the hash chain and proof-of-work immediately. The clearance is a signed
HttpOnly cookie, so every later request just passes.
protect() is an optional callback that picks what's gated; without it every route is protected. This site protects only this demo page and its API — the rest of the
site never sees the interstitial.
```
### ApiProbe.svelte
```svelte
Call protected API
{#if status !== null || body}
{status ?? 'error'} — {body}
{/if}
```
### routes.ts
```ts
import { Mochi, success, PROTECTION_CLEARANCE_COOKIE } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/protection': Mochi.page('./src/demos/protection/Protection.svelte', {
actions: {
reset: async ({ cookies }) => {
cookies.delete(PROTECTION_CLEARANCE_COOKIE, { path: '/' });
return success({ cleared: true });
},
},
}),
'/demos/protection/api': Mochi.api(async () => Response.json({ ok: true, message: 'You are cleared — this API answered.' })),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
protection: {
enabled: true,
// Without protect(), EVERY route requires browser verification. Here only
// this demo's page and its API are gated.
protect: ({ path }) => path === '/demos/protection' || path === '/demos/protection/' || path.startsWith('/demos/protection/api'),
// Proof-of-work difficulty in leading zero bits — each extra bit doubles the work.
bits: 20,
// How long a passed verification lasts before the interstitial shows again.
maxAgeMs: 4 * 60 * 60 * 1000,
},
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: queue
### Queue.svelte
```svelte
The page action calls the bounded enqueueNotification({'{ user }'}) admission helper, which adds to the descriptor returned by
Mochi.queue('demo-notifications', …)
only while fewer than 100 jobs are pending. Its process function — with
concurrency: 2, mounted via queues: [notificationQueue] in
Mochi.serve() — picks the job up and records it. Initial state comes from
serverProps; a Mochi.sse() route then pushes each completion in realtime — no polling.
```
### QueueWidget.svelte
```svelte
{#if enqueueError}
{enqueueError}
{/if}
{pending}
in flight
{processedTotal}
processed
{#if lastQueued}
Queued a notification for {lastQueued}.
{/if}
Recently processed
{#if processed.length === 0}
No notifications processed yet. Enqueue one above.
{:else}
{#each processed as entry (entry.at + entry.user)}
{entry.user}
{new Date(entry.at).toLocaleTimeString()} · {(entry.ms / 1000).toFixed(2)}s
{/each}
{/if}
```
### queue.server.ts
```ts
import { Mochi, mochiEvents } from 'mochi-framework';
import type { NotificationJob, ProcessedEntry, QueueStatus } from './types';
export const QUEUE_NAME = 'demo-notifications';
// Pending jobs are tracked on the event bus as shared server state, so every connected browser sees the same numbers.
const processed: ProcessedEntry[] = [];
let processedTotal = 0;
let reservations = 0;
export const MAX_PENDING_NOTIFICATION_JOBS = 100;
// Jobs here take under two seconds, so anything still counted after a minute lost its terminal event. Ageing those
// out matters because the count also gates admission — a stuck counter would wedge the demo at "full" for everyone.
const PENDING_TTL_MS = 60_000;
const pendingSince: number[] = [];
function pendingJobs(): number {
const cutoff = Date.now() - PENDING_TTL_MS;
while (pendingSince.length > 0 && pendingSince[0]! < cutoff) {
pendingSince.shift();
}
return pendingSince.length;
}
mochiEvents.on('queue:added', (e) => {
if (e.queue === QUEUE_NAME) {
pendingSince.push(Date.now());
}
});
const settle = (e: { queue: string }) => {
if (e.queue === QUEUE_NAME) {
pendingSince.shift();
}
};
mochiEvents.on('queue:completed', settle);
mochiEvents.on('queue:failed', settle);
// The site rides the default `queueStorage: 'memory'`, so the demo writes no queue file into the working dir;
// set `Mochi.serve({ queueStorage })` to persist.
export const notificationQueue = Mochi.queue(QUEUE_NAME, {
concurrency: 2,
process: async (job) => {
// Simulate delivery latency so the UI shows the queued → processing → done transition.
const start = Date.now();
await Bun.sleep(500 + Math.random() * 1500);
processed.push({ user: job.data.user, at: Date.now(), ms: Date.now() - start });
if (processed.length > 20) {
processed.shift();
}
processedTotal++;
return { delivered: true };
},
});
// `add()` awaits the queue write before `queue:added` fires, so concurrent submissions need the reservation to see
// each other during that window; the reservation is released once the event has taken over the count.
export function reserveNotificationSlot(): (() => void) | null {
if (pendingJobs() + reservations >= MAX_PENDING_NOTIFICATION_JOBS) {
return null;
}
reservations++;
let released = false;
return () => {
if (!released) {
reservations = Math.max(0, reservations - 1);
released = true;
}
};
}
export async function enqueueNotification(data: NotificationJob): Promise {
const release = reserveNotificationSlot();
if (!release) {
return false;
}
try {
await notificationQueue.add(data);
return true;
} finally {
release();
}
}
export function queueStatus(): QueueStatus {
return { processed: [...processed].reverse(), processedTotal, inFlight: pendingJobs() };
}
```
### types.ts
```ts
// Pure types shared between the server module (queue.server.ts) and the island.
// Components must import types from here, NOT from queue.server.ts: a type import
// from a side-effectful server module still drags that module into the SSR
// component bundle, instantiating its worker/state a second time.
export interface NotificationJob {
user: string;
}
export interface ProcessedEntry {
user: string;
at: number;
ms: number;
}
export interface QueueStatus {
processed: ProcessedEntry[];
processedTotal: number;
inFlight: number;
}
```
### usernames.ts
```ts
const names = ['alice', 'bob', 'carol', 'dave', 'erin', 'frank', 'grace', 'heidi', 'ivan', 'judy', 'mallory', 'olivia', 'peggy', 'trent', 'victor', 'wendy'];
export function randomUsername(): string {
return names[Math.floor(Math.random() * names.length)] ?? 'alice';
}
```
### routes.ts
```ts
import { Mochi, fail, success, mochiEvents } from 'mochi-framework';
import type { MochiRouteValue, MochiQueueConfig } from 'mochi-framework';
import { enqueueNotification, notificationQueue, queueStatus, QUEUE_NAME } from './queue.server';
import { randomUsername } from './usernames';
// Mounted in the site's Mochi.serve({ queues }) call — see src/routes.ts.
export const queues: MochiQueueConfig[] = [notificationQueue];
export const routes: Record = {
'/demos/queue': Mochi.page('./src/demos/queue/Queue.svelte', {
// Scoped to the enqueue POST: a page-wide limit also counts GETs (including speculation-rules
// prefetches), so a visitor reading the demo could be served a 429 instead of the page.
rateLimit: { limit: 60, window: '1m', skip: (req) => req.method !== 'POST' },
// `suggestedUser` is generated server-side so SSR and hydration agree.
serverProps: () => ({ initial: queueStatus(), suggestedUser: randomUsername() }),
actions: {
enqueue: async ({ formData }) => {
// Free-text username is safe unsanitized: it's only ever rendered through
// Svelte text interpolation (`{entry.user}`), which auto-escapes.
const user = String(formData.get('username') ?? '')
.trim()
.slice(0, 64);
if (!(await enqueueNotification({ user: user || 'anonymous' }))) {
return fail(503, { error: 'The demo queue is full. Try again after some jobs finish.' });
}
return success({ queued: user || 'anonymous' });
},
},
}),
'/demos/queue/events': Mochi.sse((stream) => {
// Broadcast the shared snapshot on enqueue and on settle, so every client's
// in-flight/processed counts move together — no per-client reconciliation.
const push = (event: { queue: string }) => {
if (event.queue === QUEUE_NAME) {
stream.send(JSON.stringify(queueStatus()));
}
};
mochiEvents.on('queue:added', push);
mochiEvents.on('queue:completed', push);
mochiEvents.on('queue:failed', push);
stream.onClose(() => {
mochiEvents.off('queue:added', push);
mochiEvents.off('queue:completed', push);
mochiEvents.off('queue:failed', push);
});
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes, queues } from './routes';
await Mochi.serve({
port: 3333,
routes,
queues,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: rate-limit
### RateLimit.svelte
```svelte
Requests used
{used} of {limit}
Window resets in
{resetIn}s
Reload this page. This route allows {limit} requests per minute per IP — after the {limit}th reload you'll get a 429 error page. Wait for the window to reset, then reload to
get back in.
```
### routes.ts
```ts
import { mkdirSync } from 'node:fs';
import { Mochi, getRequestContext, rateLimitSqliteStore } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
// Persist rate-limit counters to SQLite so they survive restarts; bun:sqlite won't create ./db itself, and it's gitignored.
mkdirSync('./db', { recursive: true });
const store = rateLimitSqliteStore({ path: './db/rate-limit.sqlite' });
export const routes: Record = {
'/demos/rate-limit': Mochi.page('./src/demos/rate-limit/RateLimit.svelte', {
rateLimit: { limit: 5, window: '1m', store },
serverProps: () => {
const rateLimit = getRequestContext().rateLimit;
return {
used: rateLimit ? rateLimit.limit - rateLimit.remaining : 1,
limit: rateLimit?.limit ?? 5,
resetIn: rateLimit?.resetIn ?? 60,
};
},
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: reload-form-data
### ReloadFormData.svelte
```svelte
Each successful submit appends the name to an in-memory list on the server. The hydrated version refetches /api/guestbook after a successful submit and updates the
list in place. The plain version submits natively, the page re-renders with a fresh guestbook serverProp, and the new name shows up after the reload.
With {'{@attach enhance(...)}'}
Plain HTML
```
### Guestbook.svelte
```svelte
{#if entries.length === 0}
No entries yet. Be the first to sign.
{:else}
{#each entries as entry (entry.id)}
{entry.name}
{formatTime(entry.at)}
{/each}
{/if}
```
### routes.ts
```ts
import { Mochi, fail, success } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
type GuestbookEntry = { id: string; name: string; at: number };
const guestbook: GuestbookEntry[] = [];
const MAX_ENTRIES = 100;
export const routes: Record = {
'/api/guestbook': Mochi.api(({ method }) => {
if (method !== 'GET') {
return new Response('Method not allowed', { status: 405 });
}
return Response.json({ entries: [...guestbook].reverse() });
}),
'/demos/reload-form-data': Mochi.page('./src/demos/reload-form-data/ReloadFormData.svelte', {
// Scoped to the sign POST: a page-wide limit also counts GETs (including speculation-rules
// prefetches), so a visitor reading the demo could be served a 429 instead of the page.
rateLimit: { limit: 60, window: '1m', skip: (req) => req.method !== 'POST' },
serverProps: () => ({ guestbook: [...guestbook].reverse() }),
actions: {
guestbookSign: ({ formData }) => {
const name = String(formData.get('name') ?? '').trim();
if (!name) {
return fail(400, { error: 'Name required' });
}
if (name.length > 50) {
return fail(400, { error: 'Name too long (max 50 chars)' });
}
guestbook.push({ id: crypto.randomUUID(), name, at: Date.now() });
if (guestbook.length > MAX_ENTRIES) {
guestbook.shift();
}
return success({});
},
},
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: request-cache
### RequestCache.svelte
```svelte
Each panel calls its own helper, but they all share one memoized analysis — a single parse per request, so one miss and four hits. Open the
debug bar (bottom right) and its Cache tab to check the stats.
Overview
Words
{nf.format(facetOverview.words)}
Unique
{nf.format(facetOverview.unique)}
Sentences
{nf.format(facetOverview.sentences)}
Reading time
~{facetOverview.readingMinutes} min
{@html codeOverview}
Top words
{#each facetTopWords as [word, count] (word)}
{word}
{nf.format(count)}
{/each}
{@html codeTopWords}
Themes
{#each facetThemes as [word, count] (word)}
{word} {nf.format(count)}
{/each}
{@html codeThemes}
Extremes
Longest word
{facetExtremes.longestWord}
Longest sentence · {facetExtremes.longestSentenceWords} words
{facetExtremes.longestSentenceExcerpt.slice(0, 260)}…
{@html codeExtremes}
Vocabulary richness
{nf.format(facetHapax)} words appear exactly once
Hapax legomena — {Math.round((facetHapax / facetOverview.unique) * 100)}% of the vocabulary
{@html codeRichness}
```
### analyzeBook.ts
```ts
import { requestMemo } from 'mochi-framework';
// Trimmed to Defoe's prose — the Gutenberg header/footer would skew word counts (and crown "unenforceability" longest word).
const RAW = await Bun.file('./src/demos/request-cache/robinson-crusoe.txt').text();
const bodyStart = RAW.indexOf('***', RAW.indexOf('*** START OF') + 3) + 3;
const BOOK = RAW.slice(bodyStart, RAW.indexOf('*** END OF'));
// Very common English words carry no signal in a top-words list; drop them so
// the narrative's real vocabulary (shore, boat, island, Friday…) rises to the top.
const STOPWORDS = new Set(
'a an the and or but if of to in into on upon at by for from with without within as than then so such no not nor only own same other another some any each few many more most all both very much well now here there when where while though yet also about before after over under again ever never how what which who whom whose that this these those i me my we us our you your he him his she her it its they them their been being am is are was were be do does did done have has had having would should could shall will may might must can cannot thus therefore hence unto amongst out up down'.split(
' ',
),
);
const THEME_WORDS = ['island', 'sea', 'ship', 'god', 'friday', 'money', 'fear'] as const;
export interface Analysis {
words: number;
unique: number;
sentences: number;
/** Estimated reading time in minutes at 250 wpm. */
readingMinutes: number;
/** All words sorted by descending frequency, stopwords excluded. */
topWords: Array<[string, number]>;
themes: Array<[string, number]>;
longestSentenceWords: number;
longestSentenceExcerpt: string;
longestWord: string;
/** Count of words appearing exactly once (hapax legomena). */
hapax: number;
}
// ~16-25ms of pure CPU per call — this is the unit of work requestMemo() below runs once instead of once per facet.
function analyze(text: string): Analysis {
const words = text.toLowerCase().match(/[a-z']+/g) ?? [];
const freq = new Map();
for (const w of words) {
freq.set(w, (freq.get(w) ?? 0) + 1);
}
const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 0);
let longestSentenceWords = 0;
let longestSentenceExcerpt = '';
for (const s of sentences) {
const n = (s.match(/[a-z']+/gi) ?? []).length;
if (n > longestSentenceWords) {
longestSentenceWords = n;
longestSentenceExcerpt = s.replace(/\s+/g, ' ').trim();
}
}
let longestWord = '';
let hapax = 0;
for (const [word, count] of freq) {
if (count === 1) {
hapax++;
}
if (word.length > longestWord.length) {
longestWord = word;
}
}
const topWords = [...freq.entries()].filter(([w]) => !STOPWORDS.has(w)).sort((a, b) => b[1] - a[1]);
const themes = THEME_WORDS.map((w) => [w, freq.get(w) ?? 0] as [string, number]);
return {
words: words.length,
unique: freq.size,
sentences: sentences.length,
readingMinutes: Math.round(words.length / 250),
topWords,
themes,
longestSentenceWords,
longestSentenceExcerpt,
longestWord,
hapax,
};
}
// Zero-arg key means one shared cache entry — however many facet helpers below call this, the book parses once per request.
const analyzeCached = requestMemo(() => analyze(BOOK), { namespace: 'demo:crusoe' });
export interface Overview {
words: number;
unique: number;
sentences: number;
readingMinutes: number;
}
export function overview(): Overview {
const a = analyzeCached();
return { words: a.words, unique: a.unique, sentences: a.sentences, readingMinutes: a.readingMinutes };
}
export function topWords(): Array<[string, number]> {
return analyzeCached().topWords.slice(0, 12);
}
export function themes(): Array<[string, number]> {
return analyzeCached().themes;
}
export interface Extremes {
longestSentenceWords: number;
longestSentenceExcerpt: string;
longestWord: string;
}
export function extremes(): Extremes {
const a = analyzeCached();
return {
longestSentenceWords: a.longestSentenceWords,
longestSentenceExcerpt: a.longestSentenceExcerpt,
longestWord: a.longestWord,
};
}
export function richness(): number {
return analyzeCached().hapax;
}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/request-cache': Mochi.page('./src/demos/request-cache/RequestCache.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: request-id
### RequestId.svelte
```svelte
This page render
{requestId}
Reload the page to get a fresh id — each request re-runs the resolver.
```
### Fetcher.svelte
```svelte
{loading ? 'Fetching…' : 'Fetch /demos/request-id/api'}
{#if fetched}
{fetched}
A different id — this fetch is a separate HTTP request, so it gets its own.
{/if}
```
### routes.ts
```ts
import { Mochi, getRequestContext } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/request-id': Mochi.page('./src/demos/request-id/RequestId.svelte', {
serverProps: () => ({ requestId: getRequestContext().requestId }),
}),
'/demos/request-id/api': Mochi.api(() => Response.json({ requestId: getRequestContext().requestId })),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: runed
### Runed.svelte
```svelte
Runed needs no special setup — it's a plain Svelte 5 runes library, so you install it and import from
runed. In Mochi it bundles straight into whichever island imports it.
Then import a utility and use it like any rune — e.g. the Debounced value driving the first card:
Explore the full toolkit at runed.dev .
Reactivity & timing
Debounced, Throttled, Previous, and watch — one input, four derived views.
Elements & observers
ElementSize + useResizeObserver, IsInViewport, activeElement, and IsFocusWithin.
```
### Reactivity.svelte
```svelte
Type here
live {text || '—'}
Debounced (400ms) {debounced.current || '—'}
Throttled (400ms) {throttled.current || '—'}
Previous {previous.current ?? '—'}
watch() change log
{#if log.length === 0}
Waiting for the debounced value to settle…
{:else}
{#each log as entry (entry.id)}
{entry.text}
{/each}
{/if}
```
### StateCard.svelte
```svelte
StateHistory
undo / redo any $state
count--}>−
{count}
count++}>+
Undo
Redo
{history.log.length} snapshot{history.log.length === 1 ? '' : 's'} recorded
PersistedState
localStorage · survives reload · syncs tabs
persisted.current--}>−
{persisted.current}
persisted.current++}>+
Reload the page — the value sticks. Open a second tab to watch it sync.
IsMounted
false during SSR, true after hydration
{#if isMounted.current}
mounted (client)
{:else}
not mounted (server)
{/if}
```
### Elements.svelte
```svelte
ElementSize + useResizeObserver
drag the corner of the box
{Math.round(size.width)} × {Math.round(size.height)}
{resizes} resize event{resizes === 1 ? '' : 's'}
IsInViewport
scroll the target in and out
{#if inViewport.current}
visible
{:else}
hidden
{/if}
activeElement + IsFocusWithin
tab through the fields
focused: {activeElement.current?.localName ?? 'none'}
{#if focusWithin.current}
focus within
{/if}
```
### Sensors.svelte
```svelte
PressedKeys
hold any keys — try Ctrl + Shift
{#if keys.all.length === 0}
No keys pressed
{:else}
{#each keys.all as key (key)}
{key}
{/each}
{/if}
IsIdle
stop moving the mouse for 2s
{#if idle.current}
idle
{:else}
active
{/if}
Last active: {new Date(idle.lastActive).toLocaleTimeString()}
```
### AsyncFsm.svelte
```svelte
FiniteStateMachine
_enter hooks auto-advance via .debounce()
{light.current}
light.send('next')}>Next →
{auto ? 'Pause' : 'Resume'}
resource
debounced fetch with cancellation
{#if search.loading}
Loading…
{:else if search.error}
Error: {search.error.message}
{:else}
{search.current?.matches.length ?? 0} match{search.current?.matches.length === 1 ? '' : 'es'}
{/if}
{#each search.current?.matches ?? [] as fruit (fruit)}
{fruit}
{/each}
```
### routes.ts
```ts
import { Mochi, getRequestContext } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
const FRUITS = ['apple', 'apricot', 'banana', 'blueberry', 'cherry', 'grape', 'lemon', 'mango', 'orange', 'peach', 'pear', 'plum'];
export const routes: Record = {
'/demos/runed': Mochi.page('./src/demos/runed/Runed.svelte'),
'/api/runed/search': Mochi.api(() => {
const { url } = getRequestContext();
const q = (url.searchParams.get('q') ?? '').toLowerCase();
const matches = q ? FRUITS.filter((f) => f.includes(q)) : FRUITS;
return Response.json({ matches });
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: server-island
### ServerIsland.svelte
```svelte
The islands below are delayed on purpose to show the loading state.
Loading server island
Loading server island
```
### ServerGreeting.svelte
```svelte
{
mouseX = e.clientX;
mouseY = e.clientY;
}}
/>
Hello, {displayName} ! 🏝️
Rendered at {renderedAt}
Mouse position: {mouseX}, {mouseY}
$props.id(): {islandId}
bigProp: {bigProp}
bigProp length: {bigProp.length}
```
### ServerNoProps.svelte
```svelte
Server island without props
Random number generated on server: {randomNumber}
$props.id(): {islandId}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/server-island': Mochi.page('./src/demos/server-island/ServerIsland.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: server-props
### ServerProps.svelte
```svelte
Rendered at
{renderedAt}
Random number
{random}
Your User-Agent
{userAgent}
Reload the page to see the values change — each request re-runs the resolver.
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/server-props': Mochi.page('./src/demos/server-props/ServerProps.svelte', {
serverProps: (req) => ({
renderedAt: new Date().toISOString(),
userAgent: req.headers.get('user-agent') ?? 'unknown',
random: Math.floor(Math.random() * 10_000),
}),
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: shared-state
### SharedState.svelte
```svelte
```
### CounterButton.svelte
```svelte
♥ Likes
{getLikes() ?? 0}
```
### likes.svelte.ts
```ts
const STORAGE_KEY = 'likes';
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null;
let likes: number | null = $state(stored !== null ? Number(stored) : null);
export function getLikes(): number | null {
return likes;
}
export function like() {
likes = (likes ?? 0) + 1;
localStorage.setItem(STORAGE_KEY, String(likes));
}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/shared-state': Mochi.page('./src/demos/shared-state/SharedState.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: static-dirs
### StaticDirsDemo.svelte
```svelte
The gallery below is the site's images/ folder, mounted at /gallery. Each
<img> points straight at the file on disk — a plain path, no query string, no signing, unlike the transformed
image pipeline .
{#each photos as n (n)}
/gallery/mochi-{n}.jpg
{/each}
When to reach for it
staticDirs is one route per mount, so it stays cheap for large or generated trees — a media library, a docs export, another tool's build directory. It serves
dotfiles and returns Bun's bare 404 on a miss. For ordinary site assets keep using publicDir, which registers one route per file, skips dotfiles, and falls through
to your error page.
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/static-dirs': Mochi.page('./src/demos/static-dirs/StaticDirsDemo.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
// Mount a directory tree under a URL prefix — one Bun route, files served straight from disk.
staticDirs: { '/gallery': './images' },
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: streams
### Streams.svelte
```svelte
```
### RealtimeClocks.svelte
```svelte
{wsTime}
/ws/time
{sseTime}
/sse/time
$props.id(): {islandId}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/streams': Mochi.page('./src/demos/streams/Streams.svelte'),
'/ws/time': (() => {
const intervals = new WeakMap();
return Mochi.ws({
open(ws) {
ws.send(new Date().toISOString());
const interval = setInterval(() => {
ws.send(new Date().toISOString());
}, 1000);
intervals.set(ws, interval);
},
message() {},
close(ws) {
clearInterval(intervals.get(ws));
intervals.delete(ws);
},
});
})(),
'/sse/time': Mochi.sse((stream) => {
stream.send(new Date().toISOString());
const interval = setInterval(() => {
stream.send(new Date().toISOString());
}, 1000);
stream.onClose(() => clearInterval(interval));
}),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: tanstack-table
### TanStackTable.svelte
```svelte
TanStack Table needs no special setup — it's a headless, framework-agnostic table library with a Svelte 5 runes adapter. You install it and import from @tanstack/svelte-table; in Mochi it bundles straight into whichever island imports it.
Declare which features you need with tableFeatures, pass your columns and
data, then render the header/cell definitions with <FlexRender />:
Full API at tanstack.com/table .
Read-only table — server-rendered, zero JS
The whole table is plain server HTML: no mochi:hydrate, so nothing ships to the browser. This only makes sense when you don't need sorting, filtering, or any
client interaction — otherwise reach for an island.
```
### BasicTable.svelte
```svelte
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
{#each headerGroup.headers as header (header.id)}
{#if !header.isPlaceholder}
{/if}
{/each}
{/each}
{#each table.getRowModel().rows as row (row.id)}
{#each row.getAllCells() as cell (cell.id)}
{/each}
{/each}
```
### SortableTable.svelte
```svelte
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
{#each headerGroup.headers as header (header.id)}
{#if !header.isPlaceholder}
{#if header.column.getIsSorted() === 'asc'}
▲
{:else if header.column.getIsSorted() === 'desc'}
▼
{/if}
{/if}
{/each}
{/each}
{#each table.getRowModel().rows as row (row.id)}
{#each row.getAllCells() as cell (cell.id)}
{/each}
{/each}
```
### data.ts
```ts
export type Person = { firstName: string; lastName: string; age: number };
export const people: Person[] = [
{ firstName: 'tanner', lastName: 'linsley', age: 24 },
{ firstName: 'tandy', lastName: 'miller', age: 40 },
{ firstName: 'joe', lastName: 'dirte', age: 45 },
{ firstName: 'kevin', lastName: 'vandy', age: 33 },
{ firstName: 'nora', lastName: 'reed', age: 29 },
];
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/tanstack-table': Mochi.page('./src/demos/tanstack-table/TanStackTable.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: url
### Url.svelte
```svelte
```
### UrlInfo.svelte
```svelte
{label} ({env})
href {url.href}
origin {url.origin}
pathname {url.pathname}
search {url.search || '(empty)'}
hash
{url.hash || '(empty)'}
{#if isServer}never sent to the server {/if}
{#if getParams().length > 0}
searchParams
{#each getParams() as [key, value] (key)}
{key} {value}
{/each}
{/if}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
export const routes: Record = {
'/demos/url': Mochi.page('./src/demos/url/Url.svelte'),
};
```
### index.ts
```ts
import { Mochi, logger } from 'mochi-framework';
import { routes } from './routes';
await Mochi.serve({
port: 3333,
routes,
});
logger.info('Server running at http://localhost:3333');
```
## Demo: varlock
### Varlock.svelte
```svelte
Varlock turns your
.env into a validated, typed schema — a .env.schema annotated with JSDoc-style decorators (@type, @required,
@sensitive). Mochi renders every page on the server on each request, so there's nothing to prerender or statically inline: you load() the schema
once at boot, then read live values through the typed ENV proxy during SSR.
Point package.json's varlock.loadPath at your schema, then do this once at the top of your server entry — index.ts, before
Mochi.serve() — so config is validated before the server boots and every request can read it:
The index.ts tab below shows the full wiring, including patchGlobalConsole() to keep secrets out of your logs. See the decorator DSL at
varlock.dev .
Variable Value Reads as
{#each config.items as item (item.key)}
{item.key}
{#if item.isSensitive}@sensitive {/if}
{#if item.isSensitive}
••••••••
{:else}
{item.value}
{/if}
{item.jsType}
{/each}
DEMO_API_PORT arrives as a JavaScript number ({config.apiPort}), and
DEMO_API_URL already has its {'${DEMO_API_PORT}'} reference expanded to
{config.apiUrl}. The @sensitive key never leaves the server — it's masked before it reaches the props, and patchGlobalConsole() keeps it
out of your logs.
```
### env.ts
```ts
import { load } from 'varlock';
import { ENV } from 'varlock/env';
// varlock's CLI generates this augmentation from the schema; we inline it so `ENV.KEY` stays fully typed.
declare module 'varlock/env' {
interface TypedEnvSchema {
DEMO_APP_ENV: 'development' | 'preview' | 'production';
DEMO_API_PORT: number;
DEMO_API_URL: string;
DEMO_SECRET_KEY: string;
}
}
export interface VarlockItem {
key: string;
value: string | null;
jsType: string;
isSensitive: boolean;
}
export interface VarlockConfig {
items: VarlockItem[];
apiUrl: string;
apiPort: number;
}
let cached: VarlockConfig | null = null;
export async function loadVarlockConfig(): Promise {
if (cached) {
return cached;
}
await load();
const graph = JSON.parse(process.env.__VARLOCK_ENV ?? '{}') as {
config?: Record;
};
const items: VarlockItem[] = Object.entries(graph.config ?? {}).map(([key, item]) => ({
key,
isSensitive: Boolean(item.isSensitive),
jsType: typeof item.value,
value: item.isSensitive ? null : String(item.value),
}));
cached = { items, apiUrl: ENV.DEMO_API_URL, apiPort: ENV.DEMO_API_PORT };
return cached;
}
```
### routes.ts
```ts
import { Mochi } from 'mochi-framework';
import type { MochiRouteValue } from 'mochi-framework';
import { loadVarlockConfig } from './env';
export const routes: Record = {
'/demos/varlock': Mochi.page('./src/demos/varlock/Varlock.svelte', {
serverProps: async () => ({ config: await loadVarlockConfig() }),
}),
};
```
### .env.schema
```bash
# @defaultSensitive=false @defaultRequired=infer @currentEnv=$DEMO_APP_ENV
# ---
# Which environment we're running in — drives automatic loading of `.env.` files.
# @type=enum(development, preview, production)
DEMO_APP_ENV=development
# The port the API listens on — coerced from string to a number.
# @type=port
DEMO_API_PORT=8080
# Built by expanding another variable — `${DEMO_API_PORT}` is resolved at load time.
# @type=url
DEMO_API_URL=http://localhost:${DEMO_API_PORT}
# A secret: required, redacted from logs, and validated to start with `sk-`.
# @required @sensitive @type=string(startsWith=sk-)
DEMO_SECRET_KEY=sk-demo-000000000000
```
### index.ts
```ts
import { load } from 'varlock';
import { patchGlobalConsole } from 'varlock/patch-console';
import { ENV } from 'varlock/env';
import { Mochi } from 'mochi-framework';
import { routes } from './routes';
// Parse + validate `.env.schema` before any code reads config; throws on a schema violation.
await load();
// Redact every `@sensitive` value from console output for the rest of the process.
patchGlobalConsole();
await Mochi.serve({
port: ENV.DEMO_API_PORT,
development: ENV.DEMO_APP_ENV === 'development',
routes,
});
```
## Demo: view-transitions
### PageOne.svelte
```svelte
```
### PageTwo.svelte
```svelte
```
### TransitionCard.svelte
```svelte
Transition:
{#each TRANSITIONS as opt (opt)}
{opt}
{/each}
```
### PersistentVideo.svelte
```svelte
Music:
Traditional Japanese 2 — Bounce-Bay-Records
{@html persistentVideoScript}
```
### persistent.js
```ts
// Rendered into the page via {@html} in PersistentVideo.svelte, so it runs on initial parse of every
// page — including after a cross-document navigation — resuming the video with no hydration bundle.
// The closing script tag is split across concatenation here rather than assembled in the .svelte file,
// since a literal one there would close that file's own script block early.
export const persistentVideoScript =
`
# Mochi 0.9.0
Mochi 0.9.0 is out, with 30 commits since [0.8.0](https://github.com/khromov/mochi/blob/main/packages/mochi/CHANGELOG.md). It's a smaller release, but with some breaking changes.
- [Faster builds with rsvelte](#faster-builds-with-rsvelte)
- [Request cache](#request-cache)
- [Relocatable builds](#relocatable-builds)
- [Precompiled email templates](#precompiled-email-templates)
- [Other improvements](#other-improvements)
- [Install or upgrade](#install-or-upgrade)
## Faster builds with rsvelte
[rsvelte](https://github.com/baseballyama/rsvelte) is a Rust port of the Svelte 5 compiler built on [OXC](https://oxc.rs/), and Mochi can now route component compilation through it. It's [opt-in](/docs/rsvelte/) — add `@mochi-framework/rsvelte` and pick the backend:
```ts
await Mochi.serve({
svelteCompiler: 'rsvelte',
routes,
});
```
On the `demos` preset from `bun create mochi@latest`:
| phase | svelte | rsvelte | delta |
| --------------- | -----: | ------: | -------: |
| total | 384ms | 289ms | **−25%** |
| `ssr-build` | 213ms | 137ms | **−36%** |
| `client-bundle` | 40ms | 26ms | **−35%** |
## Request cache
[The request cache](/docs/request-cache/) memoizes work for the duration of a single HTTP request, so a lookup rendered by ten components runs once. Concurrent callers share one in-flight promise. The cache is reset on every new request.
```ts
import { requestCache, requestMemo } from 'mochi-framework';
const user = await requestCache(`user:${id}`, () => db.user(id));
// Or wrap once at module scope — every importer shares the memo.
export const getUser = requestMemo((id: string) => db.user(id));
```
## Relocatable builds
Build output no longer contains a single absolute path, and the same commit built on any machine produces identical island names, bundle filenames and SSR'd HTML.
```ts
Mochi.serve({ manifest: '/srv/app/build/manifest.json' });
```
**Breaking change:** re-run `mochi-framework build` when you upgrade — the [manifest](/docs/production-builds/#relocatable-builds) is now versioned, and an older build throws at startup.
### The public directory
`publicDir` is no longer copied into the build — the runtime scans it from disk at boot.
**Breaking change:** keep `public` out of your `.dockerignore`.
## Precompiled email templates
`mochi-framework build` now compiles every template in `src/emails/` into the manifest, so sending no longer waits on a cold compile.
```ts
await Mochi.email({ to, subject: 'Welcome', component: './src/emails/Welcome.svelte', props });
```
**Breaking change:** [email templates](/docs/email/#svelte-templates) must live in `src/emails/`.
## Other improvements
- **Captcha** — [` `](/docs/captcha/) no longer hangs on slow mobile browsers, and its proof-of-work difficulty and solve budget are now configurable.
- **Extensions** — pulling a [server-only module](/docs/extensions/#server-only) into a client bundle now fails the build.
- **Svelte Shaker** — [`optimize`](/docs/svelte-shaker/) requires `svelte-shaker` 0.18.1 or newer, which fixes a bug that stripped `mochi:` directives so islands never hydrated.
## Install or upgrade
New project:
```sh
bun create mochi@latest my-app
```
Existing project — bump the dependency and restart the dev server:
```sh
bun add mochi-framework@latest # or @0.9.0 to pin
bun run dev
```
The [full changelog](https://github.com/khromov/mochi/blob/main/packages/mochi/CHANGELOG.md) has everything else. Mochi is still early and in alpha — if something breaks, [Discord](/discord/) or a [GitHub issue](https://github.com/khromov/mochi/issues) both work.
---
title: 'Mochi 0.8.0'
slug: mochi-0-8-0
description: 'Queues, transactional email, image transforms, captcha and rate limiting — 93 commits since 0.7.0.'
date: '2026-07-21'
author: stanislav
---
# Mochi 0.8.0
Mochi 0.8.0 is out — bringing 93 commits since [0.7.0](https://github.com/khromov/mochi/blob/main/packages/mochi/CHANGELOG.md) — the biggest release so far. The main features in this release are:
- [Image transformations](#image-transformations)
- [Email sending](#email-sending)
- [Queues](#queues)
- [Form captcha](#form-captcha)
- [Rate limiting](#rate-limiting)
- [Client-only components](#client-only-components)
- [Debug bar improvements](#debug-bar-improvements)
- [Other improvements](#other-improvements)
- [Looking ahead](#looking-ahead)
- [Install or upgrade](#install-or-upgrade)
## Image transformations
Mochi now supports [runtime image transformations](/docs/images/). Images are also automatically cached. Both Vite-style imports _and external servers_ are supported for fetching images. We also get a new ` ` component for responsive images.
```ts
await Mochi.serve({
image: {
sizes: {
thumbnail: { width: 200, height: 200, fit: 'inside', format: 'webp', quality: 80 },
},
},
// …
});
```
Then reference the size by name wherever you need it — the source is either a Vite-style import or a remote URL:
```svelte
```
Every tile below is the same kind of source photo run through a different named size declared on this site:
## Email sending
[`Mochi.email()`](/docs/email/) brings first-class support for sending emails via Mochi. After configuring an email transport in `Mochi.serve()`, you can send emails from a page action, an API route or even a queue job. Making templates is a breeze thanks to the built-in support for using Svelte components as email templates, with automatic CSS inlining.
```ts
await Mochi.email({
to: 'alice@example.com',
subject: 'Reset your password',
html: 'Click here to reset.
',
});
```
In development every message also lands in a development outbox, so you can read what your app just sent without any extra setup:
The dev outbox.
## Queues
Use [`Mochi.queue()`](/docs/queues/) to easily defer work to the background and take it off the request path — for example sending email, encoding media or calling a slow API.
```ts
await Mochi.serve({
routes,
queues: {
emails: Mochi.queue<{ to: string }>({
concurrency: 10,
process: async (job) => sendEmail(job.data.to),
}),
},
});
await Mochi.getQueue<{ to: string }>('emails').add('send', { to: 'alice@example.com' });
```
## Form captcha
[` `](/docs/captcha/) is a simple CAPTCHA based on cryptographic proof-of-work — no third-party service or extra costs.
You'll find a real example below:
The [captcha demo](/demos/captcha/) covers the failure modes — replay, expiry, and a submit that never solved the slider.
## Rate limiting
Any `Mochi.page()` or `Mochi.api()` route can now take a [`rateLimit`](/docs/rate-limiting/) config, allowing you to easily rate limit a route.
```ts
'/api/data': Mochi.api(handler, {
rateLimit: { limit: 100, window: '1m' },
}),
```
A `skip` callback decides per request whether to spend quota:
```ts
'/admin': Mochi.page('./src/admin/Admin.svelte', {
rateLimit: {
limit: 10,
window: '15m',
ban: { threshold: 3, duration: '1h' },
skip: (req) => {
const header = req.headers.get('Authorization');
return !header || credentialsMatch(header);
},
},
}),
```
## Client-only components
Some components can't render on the server at all — canvas, `localStorage`, third-party browser SDKs. The new [`mochi:clientOnly`](/docs/client-only/) directive emits an empty island wrapper during SSR and mounts the component in the browser, with optional fallback markup as children. There's also a `:visible` variant that waits for the viewport.
```svelte
```
## Debug bar improvements
The dev [debug bar](/docs/debug-bar/) grew alongside the new features: an **Images** tab for inspecting every transform on the page, a **Cache** tab for watching cache reads and writes, and an **email outbox** indicator that lights up when your app sends mail.
The debug bar, with the new Images, Cache and email-outbox controls.
## Other improvements
- **Logging** — the severity each framework event lands on is now remappable per app via the `consoleLogger:level` [filter](/docs/extensions/), so you can promote what you care about and demote what you don't without moving the [global level](/docs/logging/).
- **CLI** — the `mochi-framework` command line tool got its own [reference page](/docs/cli/), covering `build`, `generate-key` and `update-skill`.
- **Performance** — faster builds, quicker dev HMR, and smaller client bundles across the board.
- **Package updates** — dependencies across the monorepo bumped to their latest versions.
## Looking ahead
This release was bigger than normal, and a lot of the groundwork was laid in preparation for adding further built-in primitives. Some of the features currently in the pipeline are native internationalization support, `Mochi.http()` (a lightweight wrapper around `fetch` with retries and caching) and `Mochi.feature()` (built-in A/B testing).
Preparation work on the future Integration API has also started. It's inspired by the [equivalent Astro API](https://docs.astro.build/en/reference/integrations-reference/) and lets external packages hook into the framework through the existing hooks and filters system.
## Install or upgrade
New project:
```sh
bun create mochi@latest my-app
```
Existing project — bump the dependency and restart the dev server:
```sh
bun add mochi-framework@latest # or @0.8.0 to pin
bun run dev
```
If you code with an AI assistant, don't forget to add or refresh the [agent skill](/docs/docs-for-llms/). Copy the line for the agent you use:
```sh
bunx mochi-framework update-skill # claude-code (default)
bunx mochi-framework update-skill opencode
bunx mochi-framework update-skill antigravity # alias: agy
bunx mochi-framework update-skill codex
```
The [full changelog](https://github.com/khromov/mochi/blob/main/packages/mochi/CHANGELOG.md) has everything else, including the performance work on builds and dev HMR. Mochi is still early and in alpha — if something breaks, [Discord](/discord/) or a [GitHub issue](https://github.com/khromov/mochi/issues) both work.
---
title: Mochi on This Week in Svelte
slug: this-week-in-svelte
description: A live walkthrough of Mochi with Paolo Ricciuti on This Week in Svelte.
date: '2026-07-20'
author: stanislav
---
# Mochi on This Week in Svelte
I joined Paolo Ricciuti on _This Week in Svelte_ to demo Mochi and talk through the thinking behind it. The Mochi segment starts at [40:16](https://www.youtube.com/watch?v=HCpuzfRdVvI&t=2535s).
We spent a while on SvelteKit, and where Mochi goes a different way. SvelteKit has to run everywhere — static hosts, serverless, the edge — and that generality shapes what its API can offer. Mochi only targets a stateful server, so it can lean on things SvelteKit can't assume you have — real-time, caching, queues and image resizing are all in the box.
The rest of the segment was live coding:
- A brand new app from `bun create mochi@latest`.
- A click counter that stubbornly did nothing, because the page shipped zero JavaScript, until [`mochi:hydrate`](/docs/selective-hydration/) turned it into an island and the first script tag showed up in the network tab.
- The same counter as a [server island](/docs/server-islands/) with `mochi:defer`, fetched in its own request so it can be cached separately from the page around it.
- A Pokémon page pulling live data from an API, first through `serverProps` on the route, then with a plain `await` at the top of the component.
- That page nearly shipping its entire 183 KB API response to the browser as island props — spotted in the debug bar, and cut down to 22 bytes by passing only the field the island actually reads.
We also talked about what isn't there yet. File-based routing came up from Paolo and from the chat, and it's on my list to revisit before 1.0 — likely through an extensions API rather than baked into the core. I'm also not happy with hot module reloading — it works, but SvelteKit's is nicer.
Mochi is early and in alpha. The [docs](/docs/intro/) and [demos](/) are the fastest way in, and I'd genuinely like to hear what breaks — [Discord](/discord/) or a [GitHub issue](https://github.com/khromov/mochi/issues), either works.
---
title: Hello World
slug: hello-world
description: Introducing the Mochi blog.
date: '2026-07-14'
author: stanislav
---
# Hello World
Welcome to the Mochi blog. Mochi is an SSR-first framework for Svelte 5 on Bun with islands-based selective hydration.
Expect posts on new releases, Mochi internals (how and why things are implemented the way they are) and on the runtime we build on top of, Bun.
If you're new to Mochi, start with the [docs](/docs/intro/) or poke at the live demos on the [home page](/). See you in the next post.
# Changelog
# Changelog
## [0.9.1](https://github.com/khromov/mochi/compare/mochi-framework-v0.9.0...mochi-framework-v0.9.1) (2026-07-30)
### Bug Fixes
* **cache:** run one FileStorage sweeper per directory ([#227](https://github.com/khromov/mochi/issues/227)) ([45f2c7b](https://github.com/khromov/mochi/commit/45f2c7b6ab3153a41d2a5db14b33b0f97e6561b6))
## [0.9.0](https://github.com/khromov/mochi/compare/mochi-framework-v0.8.2...mochi-framework-v0.9.0) (2026-07-28)
### ⚠ BREAKING CHANGES
* **build:** precompile src/emails templates into the manifest - email templates must now be in src/emails ([#220](https://github.com/khromov/mochi/issues/220))
* relocatable build output — manifest v2, publicDir served from disk ([#170](https://github.com/khromov/mochi/issues/170))
### Features
* add optional rsvelte compiler backend ([#197](https://github.com/khromov/mochi/issues/197)) ([df9aede](https://github.com/khromov/mochi/commit/df9aede35eb49cc9ff74a86ef867939bb84b5ccc))
* **build:** precompile src/emails templates into the manifest - email templates must now be in src/emails ([#220](https://github.com/khromov/mochi/issues/220)) ([000e193](https://github.com/khromov/mochi/commit/000e193233227f49484d99d5076b0ceb9a2ab13b))
* **captcha:** fix hang, sync PoW, a11y/CLS, configurable bits/budget ([#199](https://github.com/khromov/mochi/issues/199)) ([5028c6b](https://github.com/khromov/mochi/commit/5028c6b616119c0cd647715050cb982ac0b7f6d5))
* **extensions:** crash when server-only internals reach the client ([#206](https://github.com/khromov/mochi/issues/206)) ([ad496ee](https://github.com/khromov/mochi/commit/ad496eec94ee43127ef2b6d2c38cb44f3b313156))
* memlab heap-snapshot analyzer + property-based fuzzing suite ([#203](https://github.com/khromov/mochi/issues/203)) ([49b3c85](https://github.com/khromov/mochi/commit/49b3c857e0e1635ca1c68a9918ae5d548e80c767))
* relocatable build output — manifest v2, publicDir served from disk ([#170](https://github.com/khromov/mochi/issues/170)) ([b31c052](https://github.com/khromov/mochi/commit/b31c05277a10196a8ba73ea74b88268e7a891bc5))
* request cache ([#202](https://github.com/khromov/mochi/issues/202)) ([9dd20b6](https://github.com/khromov/mochi/commit/9dd20b675b80d8f28176f3c570e7223cf3d34be8))
* warn at boot when publicDir was non-empty at build time but empty at serve ([#214](https://github.com/khromov/mochi/issues/214)) ([f807d31](https://github.com/khromov/mochi/commit/f807d3141db80f033fc708c9c3a8763ffa55138b))
### Bug Fixes
* require svelte-shaker >=0.18.1 so mochi: directives survive shaking ([#221](https://github.com/khromov/mochi/issues/221)) ([f5b4522](https://github.com/khromov/mochi/commit/f5b45224756b2b6431b402a66be3dc9790e83cf1))
### Documentation
* **mochi:** slim down over-verbose source comments ([#217](https://github.com/khromov/mochi/issues/217)) ([82b29f6](https://github.com/khromov/mochi/commit/82b29f6bf55b6f978fbe99742a2c3fff831da34d))
## [0.8.2](https://github.com/khromov/mochi/compare/mochi-framework-v0.8.1...mochi-framework-v0.8.2) (2026-07-21)
### Bug Fixes
* **email:** stop nodemailer TS7016 leaking from the value-level import ([#194](https://github.com/khromov/mochi/issues/194)) ([9575619](https://github.com/khromov/mochi/commit/9575619a416bc2a21aaddbd99c9fc503f3067f34))
## [0.8.1](https://github.com/khromov/mochi/compare/mochi-framework-v0.8.0...mochi-framework-v0.8.1) (2026-07-21)
### Bug Fixes
* **email:** stop leaking a nodemailer TS7016 error into consumers ([#192](https://github.com/khromov/mochi/issues/192)) ([4eb74f2](https://github.com/khromov/mochi/commit/4eb74f2c80e7c886916551680d062048bcb96730))
## [0.8.0](https://github.com/khromov/mochi/compare/mochi-framework-v0.7.0...mochi-framework-v0.8.0) (2026-07-21)
### Features
* add mochi:clientOnly and mochi:clientOnly:visible directives for browser-only components ([#89](https://github.com/khromov/mochi/issues/89)) ([5f318dc](https://github.com/khromov/mochi/commit/5f318dcebc052030652947600b181fb02d2143a0))
* add Mochi.email() transactional mailer ([#140](https://github.com/khromov/mochi/issues/140)) ([58a4850](https://github.com/khromov/mochi/commit/58a485009355e3f3f0b56e4e7e110d820383c556))
* add signed image-resize API with stale-while-revalidate cache ([#65](https://github.com/khromov/mochi/issues/65)) ([d1fb6b6](https://github.com/khromov/mochi/commit/d1fb6b68ae185386ad266b5e437a716a2b0e02d4))
* **cli:** add `bunx mochi-framework generate-key` command ([#114](https://github.com/khromov/mochi/issues/114)) ([f47a029](https://github.com/khromov/mochi/commit/f47a029cfc309a3bd2c24ca4fef67917b319262f))
* **image:** support Vite-style local image imports and filesystem imports ([#169](https://github.com/khromov/mochi/issues/169)) ([33beb89](https://github.com/khromov/mochi/commit/33beb8956786e7a182fe9b9262b0d00324362029))
* **logging:** remappable console log levels ([#179](https://github.com/khromov/mochi/issues/179)) ([b559717](https://github.com/khromov/mochi/commit/b5597172918b121f0bc383b2c27b005b9992959c))
* named image sizes — defer all image transforms to the endpoint, captcha ([#144](https://github.com/khromov/mochi/issues/144)) ([e733500](https://github.com/khromov/mochi/commit/e733500255f3ab14d278e72fc8c2d06e2195549e))
* per-route and global rate limiting via @joint-ops/hitlimit-bun ([#157](https://github.com/khromov/mochi/issues/157)) ([8a51dfd](https://github.com/khromov/mochi/commit/8a51dfdd14d0972a81980ccc743d3a57bab52426))
* precompile server islands into the build manifest ([#132](https://github.com/khromov/mochi/issues/132)) ([a89cce2](https://github.com/khromov/mochi/commit/a89cce20027b8e733a4592cc6acdd010fb9fa79b))
* Reword docs and improve trailingSlash ([#116](https://github.com/khromov/mochi/issues/116)) ([7ab4fa7](https://github.com/khromov/mochi/commit/7ab4fa7e079b05a44eead2301ed6421e951195c9))
* separate dev build cache from production .mochi output ([#130](https://github.com/khromov/mochi/issues/130)) ([1b5f4f6](https://github.com/khromov/mochi/commit/1b5f4f61977a01709a13fe5a3aea4eec09091b31))
* **support:** store submissions, queue email, add admin inbox ([#174](https://github.com/khromov/mochi/issues/174)) ([a47a9d0](https://github.com/khromov/mochi/commit/a47a9d00a7b5c6efe028afaa765837bfe640f74a))
* warn on large barrel imports ([#131](https://github.com/khromov/mochi/issues/131)) ([1d1b36c](https://github.com/khromov/mochi/commit/1d1b36c81244234c8303cdf260ea13d32fe631db))
### Bug Fixes
* always reconnect the dev live-reload socket ([#178](https://github.com/khromov/mochi/issues/178)) ([ebcf467](https://github.com/khromov/mochi/commit/ebcf4677c345cf1052543e1f4d3ced86bdf977dd))
* avoid HTMLRewriter onEndTag request-context leak ([#155](https://github.com/khromov/mochi/issues/155)) ([be5e15e](https://github.com/khromov/mochi/commit/be5e15e899451977bad6a4bf31f53538c169e12a))
* **deps:** update dependencies across the monorepo ([#189](https://github.com/khromov/mochi/issues/189)) ([f220ec5](https://github.com/khromov/mochi/commit/f220ec51f77f2952eafa1bf04075b3fcda3fb63e))
* force-close connections on shutdown so the process actually exits ([#176](https://github.com/khromov/mochi/issues/176)) ([16c3b2f](https://github.com/khromov/mochi/commit/16c3b2fc64a0307d87d44a6cfa008dbdc140cec0))
* forward-slash paths in user-facing output on windows ([#163](https://github.com/khromov/mochi/issues/163)) ([98f05a8](https://github.com/khromov/mochi/commit/98f05a895ee51d8385e26cbbb4ff6dcca36c6cc5))
* fully transpile `