🍡 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 });
});

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.

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

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' },
  // …
});

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

queue:added

Fires after a job is enqueued via queue.add() / queue.addBulk(). See Queues.

FieldTypeNotes
queuestringqueue name
jobIdstringgenerated job id
jobNamestringjob name passed to add

queue:active

Fires when a worker starts processing a job.

FieldTypeNotes
queuestringqueue name
jobIdstringjob id
jobNamestringjob name
attemptnumber1-based attempt number (1 on first run)

queue:completed

Fires when a job’s processor returns successfully.

FieldTypeNotes
queuestringqueue name
jobIdstringjob id
jobNamestringjob name
attemptnumberattempt that succeeded
durationnumberprocessing ms, measured from active

queue:failed

Fires when a job’s processor throws (once per failed attempt).

FieldTypeNotes
queuestringqueue name
jobIdstringjob id
jobNamestringjob name
attemptnumberattempt that failed
durationnumberprocessing ms before the throw
errorstringthrown error message

queue:error

Fires for a worker-level error not tied to a specific job (e.g. a poll failure).

FieldTypeNotes
queuestringqueue name
errorstringerror message

email:sent

Fires after Mochi.email() hands a message to its transport (or when the email:message filter vetoes it). See Email.

FieldTypeNotes
tostring[]recipient addresses (as actually sent, post-filter)
subjectstringmessage subject
transport'smtp' \| 'custom' \| 'log' \| 'dev' \| 'suppressed'which transport delivered it; 'suppressed' when the filter vetoed
messageIdstring \| undefinedprovider/SMTP id, when the transport returns one
durationnumbersend wall-clock in ms

email:error

Fires when a transport throws while sending. Mochi.email() re-throws after emitting.

FieldTypeNotes
tostring[]recipient addresses
ccstring[] \| undefinedcc recipients, when the message had any
bccstring[] \| undefinedbcc recipients, when the message had any
subjectstringmessage subject
transport'smtp' \| 'custom' \| 'log' \| 'dev'transport that failed
errorstringerror message

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

captcha:verify

Fires when verifyCaptcha() finishes, pass or fail. verifyCaptcha() deliberately returns one generic message to the client so a bot can’t tell the failure modes apart — this event is where the real cause stays visible, which makes it the hook for spam dashboards and alerting on a spike of rejections.

FieldTypeNotes
okbooleanwhether verification passed
reasonMochiCaptchaReason'ok' \| 'malformed' \| 'expired' \| 'too-fast' \| 'bad-pow' \| 'replay'
bitsnumber \| undefineddifficulty sealed in the token; absent if it never opened
ageMsnumber \| undefinedtoken age at verification; absent if it never opened
mochiEvents.on('captcha:verify', ({ ok, reason }) => {
  if (!ok) {
    metrics.increment('captcha.rejected', { reason });
  }
});

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'

image:store

Fires when the <Image> cache commits a file to disk: a downloaded full-size original, a resized variant/thumbnail, or a ThumbHash blur placeholder. Emitted once per regeneration (concurrent misses coalesce), so it’s the hook for mirroring cache writes to durable storage like S3.

FieldTypeNotes
kind'original' \| 'variant' \| 'placeholder'which entry type was written
srcstringthe image source (URL/key) this entry derives from
pathstringabsolute path of the file just committed on disk
idstringvariantId for variant; originalId(src) otherwise
sizenumberbytes written
contentTypestringauthoritative content type; '' for placeholder
widthnumberpixel width; 0 for original and placeholder
heightnumberpixel height; 0 for original and placeholder
formatstringencoded format (e.g. 'webp'); '' for the two above

Read the file synchronously at the top of the handler — it provably exists at emit time — then offload the upload to a fire-and-forget task. A lazy await readFile(path) inside a slow handler could race the janitor sweep and miss the file.

import { readFileSync } from 'node:fs';
import { mochiEvents } from 'mochi-framework';

mochiEvents.on('image:store', ({ kind, src, path, contentType }) => {
  const body = readFileSync(path); // sync: the file is guaranteed present now
  void s3.putObject({ Bucket, Key: `img/${kind}/${src}`, Body: body, ContentType: contentType });
});

image:delete

Fires when the <Image> cache removes a file from disk — evicted by the janitor sweep, superseded by a newer generation, or explicitly invalidated. Pair it with image:store to keep an S3 mirror in sync. Bulk invalidateSrc() only emits per-file deletes while a subscriber is registered.

FieldTypeNotes
kind'original' \| 'variant' \| 'placeholder'which entry type was removed
srcstringthe image source this entry derived from
pathstringabsolute path of the removed file
idstringsame id scheme as image:store
sizenumberbytes reclaimed (0 if the file was already gone)
reason'evicted' \| 'superseded' \| 'invalidated'evicted = past its window (sweep); superseded = newer generation; invalidated = explicit invalidate call
mochiEvents.on('image:delete', ({ kind, src, path }) => {
  void s3.deleteObject({ Bucket, Key: `img/${kind}/${src}` });
});

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.

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.

See it in action

Live demos showing key concepts from this page