🍡 mochi

SSR framework for Svelte 5 + Bun with islands-based selective hydration

On this page

Events

Mochi exposes a process-wide mitt emitter named mochiEvents. Subscribe from application code to feed metrics, audit logs, custom log destinations, or anything else that needs a structured view of server activity.

Event names use a namespace:action convention. Every key is in the typed MochiEventMap, so handlers receive a precise payload type without casts.

mochiEvents.on

Subscribing

Two patterns:

import { mochiEvents } from 'mochi-framework';

mochiEvents.on('request', ({ method, path, status, duration }) => {
  metrics.timing('http.request', duration, { method, path, status });
});

Handlers run synchronously. Do NOT await long-running work inside a handler; instead, fire-and-forget to your metrics or log client so the next emission is not stalled.

mochiEvents.setHandler

Use setHandler(name, type, handler) to register a named subscriber. It replaces any prior handler stored under the same name, so dev re-imports of the same module never pile up duplicate listeners.

import { mochiEvents } from 'mochi-framework';

mochiEvents.setHandler('metrics:request', 'request', ({ status, duration }) => {
  metrics.timing('http.request', duration, { status });
});

Namespace name (metrics:request, not request) so unrelated subsystems do not silently evict each other.

Do NOT mix setHandler with mochiEvents.off() for the same name; instead, call setHandler(name, type, noop) or rely on a fresh setHandler(name, …) to swap the handler — the name table is maintained only by setHandler.

hasSubscribers

Use hasSubscribers(name) to skip payload construction when nobody is listening:

import { hasSubscribers, mochiEvents } from 'mochi-framework';

if (hasSubscribers('compile:error')) {
  mochiEvents.emit('compile:error', expensivePayload());
}

Do NOT wrap every emission in hasSubscribers; instead, reach for it only when the payload involves loops, allocations, or stack capture. The built-in events emit unconditionally — their object literals are below noise.

requestId correlation

Every HTTP request carries a stable requestId on request, error, action:invoke, and action:complete. Use it to stitch a 500 trace together (the error payload + the matching request payload). The same id is on the request context:

import { getRequestContext } from 'mochi-framework';

const { requestId } = getRequestContext();

To honour an upstream id from a trusted reverse proxy, set proxy.requestIdHeader on Mochi.serve():

Mochi.serve({
  proxy: { requestIdHeader: 'X-Request-Id' },
  // …
});

Do NOT enable proxy.requestIdHeader for traffic you do not control; instead, leave it unset so the framework generates an id with Bun.randomUUIDv7() — clients can spoof any header, smearing log lines for unrelated requests together.

Event reference

Each event ships a typed payload. The fields below match MochiEventMap in events.ts.

request

Fires once per HTTP response, including CSRF rejects. Covers both Mochi.page and Mochi.api routes.

FieldTypeNotes
requestIdstringcorrelation id
kind'page' \| 'api'which route type handled it
methodstringHTTP method
pathstringURL pathname
statusnumberresponse status code
durationnumberwall-clock ms, end-to-end
warmupboolean \| undefinedtrue when issued by route warmup, not a real client
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

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

ws:close

Fires when a WebSocket connection closes.

FieldTypeNotes
pathstringURL pathname
durationnumberms the socket was open
codenumberWebSocket close code
reasonstringclose reason (may be empty)

sse:open

Fires when an SSE stream starts (the ReadableStream is pulled by the runtime).

FieldTypeNotes
pathstringURL pathname

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

sse:close

Fires when the SSE stream closes (client disconnect or explicit close).

FieldTypeNotes
pathstringURL pathname
durationnumberms the stream was open

server:start

Fires once after Bun.serve() binds the listening socket.

FieldTypeNotes
portnumber \| undefinedbound TCP port (absent over Unix)
hostnamestring \| undefinedbound hostname if any
developmentbooleandev or prod mode
routes{ page: number; api: number; ws: number; sse: number }route counts by kind

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' \| undefinedthe signal received

warmup:start

Fires once when the route warmup batch begins, right after the server starts listening. Only emitted when warmup: true is set on Mochi.serve().

FieldTypeNotes
routeCountnumberstatic page routes about to be warmed

warmup:complete

Fires once after the route warmup batch finishes. Only emitted when warmup: true is set on Mochi.serve().

FieldTypeNotes
routeCountnumberstatic page routes warmed
errorCountnumberwarmup invocations that threw or 5xx’d
durationMsnumberwall-clock ms for the whole warmup batch

error

Fires when a page, API, or form action handler throws and the framework returns an error response. Useful for routing exceptions to Sentry/Rollbar/Datadog without monkey-patching.

FieldTypeNotes
requestIdstringcorrelates with the matching request
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)

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

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.

FieldTypeNotes
pathstringabsolute path of the source
ssrSizeBytesnumbersize of the SSR bundle
hydratableCountnumberhydratable islands found
serverIslandCountnumberserver islands found
durationMsnumberwall-clock time spent inside compile()

compile:error

Fires when Bun.build rejects a Svelte source. The framework still throws after emitting; the event exists for tooling that wants the structured logs.

FieldTypeNotes
pathstringsource that failed
messagestringtop-line error message
logsArray<{ file?: string; line?: number; column?: number; message: string }>per-message diagnostics from Bun

recompile:start

Fires from the dev watcher before a rebuild cycle begins. Production builds never emit. Wraps either a full SSR rebuild (trigger: 'file' | 'svelte-config') or the CSS-only fast path (trigger: 'css').

FieldTypeNotes
trigger'file' \| 'css' \| 'svelte-config'which watcher path fired
pathstringfile whose change triggered the rebuild
pageCountnumberpages about to be rebuilt (0 for the CSS path)

recompile:complete

Fires after the matching recompile:start, once the rebuild has finished and clients have been notified to reload.

clientBundleCount is the count of buildClientBundle() calls inside the cycle — for the typical 'file' trigger it should be 1 (or 0 if no hydratables are registered). A value > 1 means the registry’s bundle deferral isn’t kicking in and you’ve regressed to per-page bundling.

FieldTypeNotes
trigger'file' \| 'css' \| 'svelte-config'matches recompile:start
pathstringmatches recompile:start
pageCountnumberpages that were rebuilt
clientBundleCountnumberbuildClientBundle() invocations during cycle
durationMsnumberwall-clock ms for the whole cycle
mochiEvents.on('recompile:complete', ({ trigger, pageCount, clientBundleCount, durationMs }) => {
  logger.info(`HMR ${trigger} pages=${pageCount} bundles=${clientBundleCount} ${durationMs.toFixed(0)}ms`);
});

client-bundle:complete

Fires whenever the registry rebuilds the hydratable client bundle (one Bun.build over HydratableIsland.ts plus a per-component virtual entrypoint). Production builds emit once at startup; dev mode emits during recompileAll() and on lazy first-hit compiles for server islands.

FieldTypeNotes
entryCountnumberentrypoints fed to Bun.build (bootstrap + per-component)
outputBytesnumbersum of all output sizes (JS + CSS) from the bundle
durationMsnumberwall-clock ms inside buildClientBundle()

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 \| undefinedenvelope id; set for 'server', else undefined
kind'hydratable' \| 'server' \| 'client-hydrate'which lifecycle stage failed
messagestringerror message
stackstring \| undefinedstack trace, populated in dev only

file:change

Fires from the dev file watcher (chokidar). Production builds do not run the watcher, so this event never emits there.

FieldTypeNotes
pathstringabsolute path of the changed file
typeMochiFileChangeType'add' \| 'change' \| 'unlink' \| 'addDir' \| 'unlinkDir'

cache:read, cache:revalidate

Emitted by MochiCache — see Subscribing to cache events for payloads and a worked subscriber.

Custom events

mochiEvents is a plain mitt emitter — emit your own keys on it for quick experiments. Custom keys are absent from MochiEventMap, so handlers and emit sites lose typing.

Do NOT use mochiEvents for application events you own; instead, construct a separate emitter you control so the typed map stays accurate to the framework’s surface.

Built-in subscribers

logger() (see logger) already prints request, ws:*, sse:*, server:*, error, and cache:revalidate lines. Pass { cache: 'verbose' } to also print every cache:read, or { cache: false } to silence cache logging entirely.