SSR framework for Svelte 5 + Bun with islands-based selective hydration
Events
Mochi emits lifecycle events through a process-wide mitt emitter exported as mochiEvents. Subscribe from application code to feed metrics, audit logs, custom log destinations, or anything else that wants a structured view of what the server is doing.
Names use a namespace:action convention. Every event key is in the typed MochiEventMap, so handlers receive a precise payload type without casts.
Events:
request— every HTTP request (page or API)ws:open,ws:message,ws:close— WebSocket lifecyclesse:open,sse:message,sse:close— Server-Sent Events lifecycleserver:start,server:stop— server lifecycleerror— page/api/action handler threw, response was an error page orapiErroraction:invoke,action:complete— form action lifecyclecompile:start,compile:complete,compile:error— Svelte SSR buildisland:error— a hydratable, server, or client-hydrate island erroredfile:change— dev-only file watchercache:read,cache:revalidate— see Subscribing to cache events
Subscribing
Two patterns:
import { mochiEvents } from 'mochi-framework';
mochiEvents.on('request', ({ method, path, status, duration }) => {
metrics.timing('http.request', duration, { method, path, status });
});.on() is the standard mitt API. Handlers are sync — to do async work, kick it off without awaiting (e.g. fire-and-forget to your metrics client) so you don’t stall the next emitter.
For HMR-safe subscribers — i.e. modules that may be re-imported by the dev SSR compile cache — use setHandler instead. It registers under a name and replaces any prior handler under the same name, so re-imports don’t pile up listeners:
import { mochiEvents } from 'mochi-framework';
mochiEvents.setHandler('metrics:request', 'request', ({ status, duration }) => {
metrics.timing('http.request', duration, { status });
});Namespace the name (metrics:request, not request) so unrelated subsystems don’t silently evict each other. Don’t mix setHandler with .off() for the same name — the name table is maintained only by setHandler.
Correlating events for one request
Every HTTP request gets a stable requestId carried on request, error, action:invoke, and action:complete. Use it to stitch together a full picture (e.g. trace a 500: error payload + the matching request payload). The same id is exposed on the request context:
import { getRequestContext } from 'mochi-framework';
const { requestId } = getRequestContext();By default the framework generates the id with nanoid. To pick up an upstream id from a trusted reverse proxy instead, set proxy.requestIdHeader:
Mochi.serve({
proxy: { requestIdHeader: 'X-Request-Id' },
// …
});Only enable this behind a proxy you control — clients can spoof any header, so trusting a public-internet X-Request-Id lets attackers smear log lines for unrelated requests together.
Skipping work when nobody listens
If you emit your own custom events whose payload is expensive to build (large allocations, stack capture, structured log mapping), wrap the construction in hasSubscribers(name):
import { hasSubscribers, mochiEvents } from 'mochi-framework';
if (hasSubscribers('compile:error')) {
mochiEvents.emit('compile:error', expensivePayload());
}The built-in events emit unconditionally — their payloads are cheap, and the cost of an extra object literal per request is below noise. Reach for hasSubscribers only when payload construction would dominate.
Event reference
request
Fires after an HTTP response is finalized — once per request, including CSRF rejects. Covers both Mochi.page() and Mochi.api() routes (distinguished by kind).
| Field | Type | Notes |
|---|---|---|
requestId | string | correlation id (see above) |
kind | 'page' \| 'api' | which route type handled it |
method | string | HTTP method |
path | string | URL pathname |
status | number | response status code |
duration | number | wall-clock ms, end-to-end |
mochiEvents.on('request', ({ kind, method, path, status, duration }) => {
if (status >= 500) alerts.fire({ kind, method, path, status, duration });
});ws:open
Fires after a successful WebSocket upgrade.
| Field | Type | Notes |
|---|---|---|
path | string | URL pathname of the upgrade request |
duration | number | ms spent in the upgrade handler |
mochiEvents.on('ws:open', ({ path }) => {
metrics.increment('ws.open', { path });
});ws:message
Fires for every inbound WebSocket frame, after the user message handler returns.
| Field | Type | Notes |
|---|---|---|
path | string | URL pathname |
size | number | bytes (text length or buffer) |
type | 'text' \| 'binary' | frame kind |
mochiEvents.on('ws:message', ({ path, size, type }) => {
metrics.histogram('ws.message.size', size, { path, type });
});ws:close
Fires when a WebSocket connection closes.
| Field | Type | Notes |
|---|---|---|
path | string | URL pathname |
duration | number | ms the socket was open |
code | number | WebSocket close code |
reason | string | close reason (may be empty) |
mochiEvents.on('ws:close', ({ path, code, duration }) => {
metrics.timing('ws.session', duration, { path, code });
});sse:open
Fires when an SSE stream starts (the ReadableStream is pulled by the runtime).
| Field | Type | Notes |
|---|---|---|
path | string | URL pathname |
mochiEvents.on('sse:open', ({ path }) => {
metrics.increment('sse.open', { path });
});sse:message
Fires per stream.send() inside an SSE handler.
| Field | Type | Notes |
|---|---|---|
path | string | URL pathname |
size | number | bytes written for the data line |
event | string \| undefined | optional named event passed to send() |
mochiEvents.on('sse:message', ({ path, size, event }) => {
metrics.histogram('sse.message.size', size, { path, event: event ?? 'message' });
});sse:close
Fires when the SSE stream closes (client disconnect or explicit close).
| Field | Type | Notes |
|---|---|---|
path | string | URL pathname |
duration | number | ms the stream was open |
mochiEvents.on('sse:close', ({ path, duration }) => {
metrics.timing('sse.session', duration, { path });
});server:start
Fires once after Bun.serve() binds the listening socket. Includes a count of routes by kind.
| Field | Type | Notes |
|---|---|---|
port | number | bound port |
hostname | string \| undefined | bound hostname if any |
development | boolean | dev or prod mode |
routes | { page, api, ws, sse: number } | route counts by kind |
mochiEvents.on('server:start', ({ port, routes }) => {
log.info(`listening on ${port} — ${routes.page} pages, ${routes.api} APIs`);
});server:stop
Fires when the server is shutting down via SIGTERM/SIGINT, after the mochi:shutdown hook has run.
| Field | Type | Notes |
|---|---|---|
reason | 'signal' | what initiated the shutdown |
signal | 'SIGTERM' \| 'SIGINT' \| - | the signal received |
mochiEvents.on('server:stop', ({ reason, signal }) => {
metrics.flush(); // drain before the process exits
});error
Fires when a page, API, or form action handler throws and the framework returns an error response. Useful for routing exceptions to Sentry/Rollbar/Datadog without monkey-patching.
| Field | Type | Notes |
|---|---|---|
requestId | string | correlates with the matching request event |
kind | 'page' \| 'api' \| 'action' | which handler threw |
path | string | URL pathname + search |
method | string | HTTP method |
status | number | final response status |
message | string | error message |
stack | string \| undefined | stack trace, populated in dev only |
actionName | string \| undefined | form action name; present only when kind=action |
mochiEvents.on('error', ({ kind, path, status, message, stack }) => {
Sentry.captureException(new Error(message), { tags: { kind, path, status }, contexts: { stack } });
});action:invoke
Fires immediately before a form action handler runs. Pairs with action:complete via requestId.
| Field | Type | Notes |
|---|---|---|
requestId | string | correlates with action:complete |
path | string | URL pathname + search |
actionName | string | action name ('default' if unnamed) |
mochiEvents.on('action:invoke', ({ requestId, actionName }) => {
starts.set(requestId, performance.now());
});action:complete
Fires after a form action returns (or throws). One emission per invocation, regardless of outcome.
| Field | Type | Notes |
|---|---|---|
requestId | string | correlates with action:invoke |
path | string | URL pathname + search |
actionName | string | action name |
result | 'success' \| 'fail' \| 'redirect' \| 'error' | outcome category |
status | number \| undefined | set for fail and redirect |
mochiEvents.on('action:complete', ({ requestId, actionName, result }) => {
const ms = performance.now() - (starts.get(requestId) ?? 0);
starts.delete(requestId);
metrics.timing('form.action', ms, { actionName, result });
});compile:start
Fires before each Svelte SSR compile (skipped on cache hit).
| Field | Type | Notes |
|---|---|---|
path | string | absolute path of the source |
compile:complete
Fires after a successful compile. Pair with compile:start to time the build per file.
| Field | Type | Notes |
|---|---|---|
path | string | absolute path of the source |
ssrSizeBytes | number | size of the SSR bundle |
hydratableCount | number | hydratable islands found |
serverIslandCount | number | server islands found |
const compileStart = new Map<string, number>();
mochiEvents.on('compile:start', ({ path }) => compileStart.set(path, performance.now()));
mochiEvents.on('compile:complete', ({ path, ssrSizeBytes }) => {
const ms = performance.now() - (compileStart.get(path) ?? 0);
log.info(`compiled ${path} in ${ms}ms (${ssrSizeBytes} bytes)`);
compileStart.delete(path);
});compile:error
Fires when Bun.build rejects a Svelte source. The framework still throws after emitting; the event is for tooling that wants the structured logs.
| Field | Type | Notes |
|---|---|---|
path | string | source that failed |
message | string | top-line error message |
logs | Array<{ file?, line?, column?, message }> | per-message diagnostics from Bun |
island:error
Fires when an island fails — server-island render, hydratable SSR render, or client-side hydration. The framework still ships an error placeholder; this event lets you observe it.
| Field | Type | Notes |
|---|---|---|
componentName | string | island component identifier |
islandId | string \| undefined | DOM island-id if known |
kind | 'hydratable' \| 'server' \| 'client-hydrate' | which lifecycle stage failed |
message | string | error message |
stack | string \| undefined | stack trace, populated in dev only |
mochiEvents.on('island:error', ({ componentName, kind, message }) => {
log.error(`[${kind}] ${componentName}: ${message}`);
});file:change
Fires from the dev file watcher (chokidar). Production builds don’t run the watcher, so this event never emits there.
| Field | Type | Notes |
|---|---|---|
path | string | absolute path of the changed file |
type | MochiFileChangeType | 'add' \| 'change' \| 'unlink' \| 'addDir' \| 'unlinkDir' |
mochiEvents.on('file:change', ({ path, type }) => {
if (type === 'change' && path.endsWith('.env')) restartWorkers();
});Custom events
mochiEvents is a plain mitt emitter — you can emit your own keys on it. They won’t appear in MochiEventMap, so handlers and emit sites lose their typing. For anything beyond a quick experiment, prefer a separate emitter you control.