SSR framework for Svelte 5 + Bun with islands-based selective hydration
On this page
WebSocket routes
Mochi.ws(handlers) registers a WebSocket endpoint backed by Bun’s ServerWebSocket. The handler map carries five callbacks — upgrade, open, message, close, drain — and exposes Bun’s pub/sub primitives (ws.subscribe, ws.publish, ws.unsubscribe) on the socket.
// file: src/index.ts
import { Mochi } from 'mochi-framework';
await Mochi.serve({
routes: {
'/ws/chat': Mochi.ws({
open(ws) {
ws.subscribe('chat');
},
message(ws, message) {
ws.publish('chat', String(message));
ws.send(String(message));
},
close(ws) {
ws.unsubscribe('chat');
},
}),
},
});Only message is required; the rest are optional.
upgrade
Runs once per HTTP upgrade request. Return a value to attach to ws.data.user, or return false to reject the connection. The route’s URL params are passed as the second argument.
// file: src/index.ts
import { Mochi } from 'mochi-framework';
await Mochi.serve({
routes: {
'/ws/:room': Mochi.ws<{ userId: string; room: string }>({
upgrade(req, params) {
const userId = req.headers.get('x-user-id');
if (!userId) return false; // reject the upgrade
return { userId, room: params.room };
},
message(ws, msg) {
console.log(ws.data.user.userId, ws.data.user.room, msg);
},
}),
},
});Do NOT authenticate inside message and silently drop messages from unauthenticated sockets; instead, reject the upgrade in upgrade so the client never establishes the connection.
open
Fires once after a successful upgrade. Use it to subscribe the socket to topics or seed per-connection state.
open(ws) {
ws.subscribe('chat');
}message
Fires for every inbound frame. The payload is string | Buffer — coerce or decode it before use.
message(ws, message) {
ws.publish('chat', String(message));
}Do NOT run long synchronous work or unbounded await chains inside message; instead, hand work off to a queue or setImmediate — the callback is on the connection’s read path and blocks subsequent frames.
close
Fires once when the socket closes, with the close code and reason. Use it to release per-connection state and unsubscribe from topics.
close(ws, code, reason) {
ws.unsubscribe('chat');
}Do NOT call ws.send or ws.publish from inside close (or any time after the socket has closed); instead, treat close as terminal — sends to a closed socket throw and pub/sub from a closed socket is a no-op.
drain
Fires when the socket’s send buffer has drained after a backpressured ws.send. Resume queued writes here.
drain(ws) {
// resume buffered sends
}ws.data
Each socket carries a typed data object. Mochi reserves the internal fields __mochiRoutePattern, __mochiOpenedAt, and __mochiPath; your upgrade return value is exposed as ws.data.user.
Mochi.ws<{ userId: string }>({
upgrade(req) {
const userId = req.headers.get('x-user-id');
return userId ? { userId } : false;
},
message(ws) {
console.log(ws.data.user.userId);
},
});Do NOT write to the __mochi* fields on ws.data; instead, keep your own values under the user shape returned from upgrade.
Pub/sub
Every socket exposes Bun’s pub/sub primitives: ws.subscribe(topic), ws.publish(topic, data), ws.unsubscribe(topic). To broadcast from outside a handler, capture the server returned by Mochi.serve() and call server.publish(topic, data).
open(ws) {
ws.subscribe('chat');
},
message(ws, msg) {
ws.publish('chat', String(msg)); // fan out to every other subscriber
},Do NOT assume ws.publish echoes back to the sender; instead, call ws.send alongside ws.publish if the publisher should also receive the message.
Lifecycle events
Every WebSocket emits ws:open, ws:message, and ws:close on mochiEvents. logger() already prints them. See Events for the full payload shape and how to add custom subscribers.