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

Event index

Subscribing

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 never pile up duplicate listeners.

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

Namespace name (metrics:request, not request) so unrelated subsystems do not 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 same id is on the request context.

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

Event reference

Each event ships a typed payload matching MochiEventMap in events.ts.

request

Fires once per HTTP response, including CSRF rejects. Covers 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

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.

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. A client disconnect or an explicit close both count.

FieldTypeNotes
pathstringURL pathname
durationnumberms the stream was open

queue:added

Since: 0.10.0 (not released yet): The bulk field is new in 0.10.0.

Fires after queue.add() / queue.addBulk() enqueues a job. See Queues.

FieldTypeNotes
queuestringqueue name
jobIdstringgenerated job id
bulkboolean \| undefinedtrue when the add came from addBulk

queue:addedBulk

Since: 0.10.0 (not released yet): queue:addedBulk is new in 0.10.0.

Fires once per addBulk() call that inserted at least one job, alongside the per-job queue:added events. The console logger prints this summary instead of the per-job lines.

FieldTypeNotes
queuestringqueue name
countnumberjobs actually inserted (duplicate ids are skipped)
jobIdsstring[]ids of the inserted jobs

queue:active

Fires when a worker starts a job.

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

queue:completed

Fires when a job’s processor returns successfully.

FieldTypeNotes
queuestringqueue name
jobIdstringjob id
attemptnumberattempt that succeeded
durationnumberms the processor ran the attempt

queue:failed

Fires when a job’s processor throws. One emission per failed attempt.

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

queue:error

Fires for a queue-runtime error not tied to one job, for example a poll failure.

FieldTypeNotes
queuestring?absent for instance-level errors with no queue
errorstringerror message

email:sent

Fires after Mochi.email() hands a message to its transport. The email:message filter can veto it first. See Email.

FieldTypeNotes
tostring[]recipient addresses, as actually sent
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

Since: 0.10.0 (not released yet): The 'stop' reason is new in 0.10.0 — earlier versions emit this event only on signals.

Fires when the server shuts down — on SIGTERM / SIGINT, or a programmatic Mochi.stop() — after the mochi:shutdown hook runs.

FieldTypeNotes
reason'signal' \| 'stop'signal, or programmatic Mochi.stop()
signal'SIGTERM' \| 'SIGINT' \| undefinedthe signal received; absent for Mochi.stop()

warmup:start

Fires once when the route warmup batch begins. Only emitted with warmup: true.

FieldTypeNotes
routeCountnumberstatic page routes about to be warmed

warmup:complete

Fires once after the route warmup batch finishes. Only emitted with warmup: true.

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.

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, dev only
actionNamestring \| undefinedpresent 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 through 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, whatever the 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. A cache hit skips it.

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. It 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 finishes and clients are told to reload.

clientBundleCount counts buildClientBundle() calls inside the cycle. For a typical 'file' trigger it must be 1, or 0 when no hydratables are registered. A value above 1 means the registry’s bundle deferral stopped working and you 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

recompile:module-churn

Fires once per dev session, when the entry has been re-imported reloadCount times (default 10). Each reload re-evaluates the whole first-party module graph, so a module-scoped resource is re-created and the old one orphaned — see route-handler HMR. consoleLogger() renders it as a warn-level HMR line; suppress that line with a consoleLogger:line filter matching source.name === 'recompile:module-churn'.

FieldTypeNotes
reloadCountnumberentry re-imports so far this session (≥ 10)
import { mochiEvents } from 'mochi-framework';

mochiEvents.on('recompile:module-churn', ({ reloadCount }) => {
  console.warn(`entry re-imported ${reloadCount}× — hold resources with pinGlobal()`);
});

client-bundle:complete

Fires whenever the registry rebuilds the hydratable client bundle. 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. The client gets one generic message, but this event carries the real cause.

FieldTypeNotes
okbooleanwhether verification passed
reasonMochiCaptchaReason'ok' \| 'malformed' \| 'expired' \| 'too-fast' \| 'bad-pow' \| 'replay'
bitsnumber \| undefineddifficulty sealed in the token
ageMsnumber \| undefinedtoken age at verification

island:error

Fires when an island fails: a server-island render, a hydratable SSR render, or client-side hydration. The framework still ships an error placeholder. See Error boundaries.

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, 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, or a ThumbHash blur placeholder. Emitted once per regeneration, because concurrent misses coalesce. Use it to mirror cache writes to durable storage such as 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 such as 'webp'; '' for the two above
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. The janitor sweep evicts it, a newer generation supersedes it, or you invalidate it explicitly. Pair it with image:store to keep an S3 mirror in sync. A 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}` });
});

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

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

See it in action

Live demos showing key concepts from this page