WebSocket primitives
DaloyJS ships runtime-agnostic WebSocket primitives in src/websocket.ts (re-exported from @daloyjs/core/websocket) plus adapter wiring for @daloyjs/core/node and @daloyjs/core/bun. Both adapters accept the same handler shape — the Bun-style open / message / close / drain / error callbacks — so the same app.ws(path, handler) registration works on either runtime without changes.
On Node the adapter only installs an upgradelistener when at least one WS route is registered, so apps that don't use WebSockets pay zero overhead. On Bun the adapter forwards to Bun.serve's native websocket config.
Quick start
import { App } from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";
const app = new App();
app.ws("/chat/:room", {
open(conn, ctx) {
conn.data = { user: ctx.query.user ?? "anon", room: ctx.params.room };
conn.send(`welcome ${(conn.data as { user: string }).user}`);
},
message(conn, data, isBinary) {
// Echo back, upper-cased for text frames
conn.send(typeof data === "string" ? data.toUpperCase() : data, { binary: isBinary });
},
close(conn, code, reason) {
// release any per-connection resources here
},
});
serve(app, { port: 3000 });Handler shape
Use defineWebSocket() for full type-inference on path params (ctx.params), query (ctx.query), and per-connection state (conn.data):
import { defineWebSocket } from "@daloyjs/core";
const chatHandler = defineWebSocket({
open(conn, ctx) {
// ctx.params is typed from the path pattern "/chat/:room"
conn.data = { room: ctx.params.room, joinedAt: Date.now() };
},
message(conn, data) {
conn.send(typeof data === "string" ? data : new Uint8Array(data as ArrayBuffer));
},
close(conn, code, reason) {
// cleanup
},
error(conn, err) {
// user error handler — does not affect close code
},
drain(conn) {
// backpressure released
},
});
app.ws("/chat/:room", chatHandler);Connection API
The WebSocketConnection passed to your handlers mirrors the WHATWG WebSocket interface where it makes sense on the server side:
conn.readyState— one ofWS_READY_STATE.CONNECTING / OPEN / CLOSING / CLOSED.conn.send(data, options?)— send a text frame forstring, or a binary frame forUint8Array/ArrayBuffer. Pass{ binary: true }to force binary framing of a string.conn.ping(data?)/conn.pong(data?)— control frames; payload must be ≤ 125 bytes per RFC 6455.conn.close(code?, reason?)— graceful close (sends a CLOSE frame, fires yourclosehandler, then closes the underlying socket).conn.terminate()— immediate transport-level close, no CLOSE frame.conn.bufferedAmount,conn.protocol,conn.extensions,conn.binaryType.conn.data— opaque per-connection slot for your app state.
Protocol negotiation & upgrade hook
Optional beforeUpgrade(req, ctx) runs after the path match but before the 101 response. Return a Response to reject (handy for auth or rate-limiting), or return a string to pick a subprotocol from Sec-WebSocket-Protocol:
app.ws("/api", {
beforeUpgrade(req, ctx) {
const token = ctx.headers["authorization"]?.replace(/^Bearer /, "");
if (!token || !isValid(token)) {
return new Response("unauthorized", { status: 401 });
}
// Pick a subprotocol the client offered
const offered = (ctx.headers["sec-websocket-protocol"] ?? "").split(",").map((s) => s.trim());
return offered.includes("daloy.v1") ? "daloy.v1" : undefined;
},
open(conn) {
conn.send("ready");
},
message(conn, data) {
conn.send(data);
},
});Graceful shutdown
The Node adapter tracks every upgraded socket. When close() is invoked (or the app receives SIGTERM / SIGINT with handleSignals: true), all active WebSocket sockets are destroyed before server.close() resolves so the process exits promptly even if clients linger.
Custom adapters
If you target a runtime other than Node or Bun, import the primitives directly from @daloyjs/core/websocket:
import {
validateUpgrade,
computeAcceptKey,
FrameSink,
encodeFrame,
encodeSendPayload,
encodeClosePayload,
WS_OPCODE,
WS_CLOSE_CODE,
WS_READY_STATE,
} from "@daloyjs/core/websocket";FrameSink is a streaming RFC 6455 parser: feed it bytes via sink.push(chunk) and it dispatches onMessage (with reassembled payload + isBinary flag), onPing, onPong, onClose, and onProtocolError. UTF-8 validation on text frames is handled for you.