SSR framework for Svelte 5 + Bun with islands-based selective hydration
On this page
Development mode
Set the development flag on Mochi.serve() to switch between the dev and production runtime. It defaults to true.
// file: src/index.ts
await Mochi.serve({
development: true, // the default
routes,
});When development is true, Mochi enables:
- Live reload — a
mochi-live-reloadweb component connects to/__mochi_live_reloadand refreshes the page on file changes. - File watcher —
chokidarwatchessrc/andpublic/; edits invalidate the SSR compile cache and emitfile:changeonmochiEvents. - Debug bar —
<div id="mochi-dev-toolbar">is injected into every page and the per-request payload is seeded ontowindow.__mochi_debug. - Error overlay — build and runtime errors render via
buildErrorOverlayon top of the page (dev only). - Error console script — the same errors are also pushed through
console.errorviabuildErrorScript(runs in dev and prod). - Bundle stats — a JSON report is served at
${assetPrefix}/client/stats(default/_mochi/client/stats). - Stack traces on
errorevents —errorpayloads includestack; in productionstackisundefined.
MODE=development convention
Wire the flag from an env var so one entry file serves both bun run dev and bun run start:
// file: src/index.ts
await Mochi.serve({
development: process.env.MODE === 'development',
routes,
});// file: package.json
{
"scripts": {
"dev": "MODE=development bun src/index.ts",
"start": "bun src/index.ts"
}
}MODE is a user-space convention — Mochi reads only options.development. Passing development explicitly via your entry file keeps test runners and one-off scripts on the same behaviour.
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 set to.
// file: src/index.ts
await Mochi.serve({
development: true,
liveReload: false, // debug bar stays, WS does not
routes,
});File watcher
The watcher always covers src/ and public/. Extend it with additionalWatchPaths:
// file: src/index.ts
await Mochi.serve({
additionalWatchPaths: ['../content', './docs'],
routes,
});Defaults are always included — additionalWatchPaths is additive. Paths that don’t exist on disk are skipped silently.
Component hot-reload
Svelte component changes are picked up automatically via the SSR compile cache — edit a .svelte file and the browser will refresh.
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 of them changes 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 — new routes are registered, removed routes are cleaned up, and the route table is reloaded on the fly. WebSocket connections stay open; the browser is notified to reload so pages pick up updated serverProps.
file:change event
Every watcher event is re-emitted on mochiEvents as file:change. Use it to invalidate your own caches without restarting:
// file: src/lib/docs-cache.ts
import { mochiEvents } from 'mochi-framework';
mochiEvents.setHandler('docs:cache-clear', 'file:change', ({ path }) => {
if (path.endsWith('.md')) clearMyMarkdownCache();
});path is absolute; type is 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'. The event fires synchronously, before the debounced browser reload, so subscribers are caught up by the time the next request arrives.
In production (development: false) the watcher never starts and file:change never emits. See events for the full payload reference.
mochiEvents from Svelte SSR
The emitter is pinned on globalThis, so a subscription made from a .svelte file running on the server reaches the same instance the framework emits to. On the client, mochiEvents is a stub: on/off/setHandler are no-ops and emit logs a warning — the bus is server-only.
HMR rebuild logger lines
The built-in logger (default for Mochi.serve()) prints a structured line per save so you can see what was rebuilt and how long it took:
BUILD— one per page compiled, plus a summary line with total file count and duration. Note shows hydratable/server-island counts and SSR bundle size.BNDL— one perbuildClientBundle()call. Note shows the entrypoint count and total output bytes.HMR— one per rebuild cycle. Note shows the trigger (file/css/svelte-config),pages=N(recompiled pages), andbundles=N(calls tobuildClientBundle).
Healthy bundles= for a non-CSS save is 1 — the bundle is deferred to a single trailing call after every page has recompiled. A higher number means a regression to per-page bundling, which is O(N²) work for N hydratable pages.
Barrel-import warning
Mochi warns when a dependency drags a large module into the build graph that’s then almost entirely tree-shaken away — the classic “barrel import” smell:
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 iconBun doesn’t bundle the barrel’s thousands of modules — it tree-shakes them — 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 as it’s seen, naming the offending package, file, and how much of it survives tree-shaking:
[mochi] Heavy barrel import: "@lucide/svelte" parses @lucide/svelte/dist/icons/index.js (106 KB) on every rebuild but uses only 0% of it.A production mochi-framework build runs the same check, but collapses every offender into a single grouped summary line so a noisy build isn’t buried in warnings:
[mochi] 3 heavy barrel imports parsed but barely used (244 KB total) — slows builds. Worst: @lucide/svelte (106 KB, 0% used), …Tune or silence it via barrelWarnings on Mochi.serve() (and pass the same value to the build, see Serve options):
await Mochi.serve({
barrelWarnings: false, // silence entirely
// or keep it on but suppress a package you can't fix / raise the size threshold:
barrelWarnings: { ignore: ['@lucide/svelte'], minBytes: 100 * 1024 },
routes,
});minBytes defaults to 50 KB — the minimum parsed size of a single dependency file before it’s considered for the warning.
For silencing logic richer than a static ignore list, register the barrel:warn filter — it receives each warning and its context (pkg, file, bytes, usedRatio) and can rewrite the line or return null to drop it. See Extensions.