🍡 mochi

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:

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).

FieldTypeNotes
requestIdstringcorrelation id (see above)
kind'page' \| 'api'which route type handled it
methodstringHTTP method
pathstringURL pathname
statusnumberresponse status code
durationnumberwall-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.

FieldTypeNotes
pathstringURL pathname of the upgrade request
durationnumberms 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.

FieldTypeNotes
pathstringURL pathname
sizenumberbytes (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.

FieldTypeNotes
pathstringURL pathname
durationnumberms the socket was open
codenumberWebSocket close code
reasonstringclose 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).

FieldTypeNotes
pathstringURL pathname
mochiEvents.on('sse:open', ({ path }) => {
  metrics.increment('sse.open', { path });
});

sse:message

Fires per stream.send() inside an SSE handler.

FieldTypeNotes
pathstringURL pathname
sizenumberbytes written for the data line
eventstring \| undefinedoptional 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).

FieldTypeNotes
pathstringURL pathname
durationnumberms 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.

FieldTypeNotes
portnumberbound port
hostnamestring \| undefinedbound hostname if any
developmentbooleandev 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.

FieldTypeNotes
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.

FieldTypeNotes
requestIdstringcorrelates with the matching request event
kind'page' \| 'api' \| 'action'which handler threw
pathstringURL pathname + search
methodstringHTTP method
statusnumberfinal response status
messagestringerror message
stackstring \| undefinedstack trace, populated in dev only
actionNamestring \| undefinedform 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.

FieldTypeNotes
requestIdstringcorrelates with action:complete
pathstringURL pathname + search
actionNamestringaction 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.

FieldTypeNotes
requestIdstringcorrelates with action:invoke
pathstringURL pathname + search
actionNamestringaction name
result'success' \| 'fail' \| 'redirect' \| 'error'outcome category
statusnumber \| undefinedset 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).

FieldTypeNotes
pathstringabsolute path of the source

compile:complete

Fires after a successful compile. Pair with compile:start to time the build per file.

FieldTypeNotes
pathstringabsolute path of the source
ssrSizeBytesnumbersize of the SSR bundle
hydratableCountnumberhydratable islands found
serverIslandCountnumberserver 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.

FieldTypeNotes
pathstringsource that failed
messagestringtop-line error message
logsArray<{ 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.

FieldTypeNotes
componentNamestringisland component identifier
islandIdstring \| undefinedDOM island-id if known
kind'hydratable' \| 'server' \| 'client-hydrate'which lifecycle stage failed
messagestringerror message
stackstring \| undefinedstack 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.

FieldTypeNotes
pathstringabsolute path of the changed file
typeMochiFileChangeType'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.