WebSocket
WebSocket
@chttp/ws adds RFC 6455 WebSockets to @chttp/router through the upgrade seam described in upgrade-contract. It depends on @chttp/router only and is authored in the scriptc compilable subset.
Usage
import { createRouter } from "@chttp/router";
import type { IncomingMessage } from "node:http";
import type { Socket } from "node:net";
import { WsConn, WsHandler, WsRouter } from "@chttp/ws";
class Room extends WsHandler {
open(c: WsConn): void {
c.send("joined " + c.param("name"));
}
message(c: WsConn, data: string): void {
c.send(c.param("name") + ": " + data);
}
}
const app = createRouter();
app.withUpgrade(new WsRouter().ws("/room/:name", new Room()));
const server = app.serve({ port: 3000 });
const up: (req: IncomingMessage, socket: Socket, head: Buffer) => void = app.upgradeHandler();
server.on("upgrade", up);
Paths match exactly or with :param segments. Param values are the raw path segments (not percent decoded) and are read with c.param(name). The query string is ignored for matching.
Handlers and connections
WsHandler is a base class with four methods, all no-ops by default:
| Method | Called when |
|---|---|
open(c) |
after the 101 response was written |
message(c, data: string) |
a complete text message arrived (valid UTF-8) |
binary(c, data: Buffer) |
a complete binary message arrived |
close(c, code, reason) |
exactly once: peer close frame, protocol error, c.close(), or socket loss (code 1006) |
Handlers are subclasses, not records of functions, because scriptc fences a function value inside a record. The router keeps them in parallel arrays next to their path patterns. One handler instance serves every connection on its path, so keep per connection state in your own structures keyed by the WsConn.
WsConn has send(text), sendBinary(buf), ping(buf) (at most 125 bytes), close(code, reason), param(name), isOpen(), and the fields socket and path. Pings from the peer are answered with a pong automatically.
Handshake
The path is matched first, then the upgrade headers:
| Condition | Response |
|---|---|
| no route matches the path | 404 |
Upgrade is not websocket, or Sec-WebSocket-Version is not 13 |
426 with Sec-WebSocket-Version: 13 |
method is not GET, or Sec-WebSocket-Key is missing |
400 |
| otherwise | 101 with Sec-WebSocket-Accept (sha1 of key plus the RFC GUID, base64) |
Error responses are written by the router and the socket is ended. No subprotocol or origin negotiation happens; check req yourself in a custom UpgradeDispatch if you need it.
Framing and limits
The parser is incremental and non-recursive over Buffer.concat. It handles 7, 16 and 64 bit lengths, unmasking, fragmented messages with interleaved control frames, ping, pong and close with code validation. It answers violations by sending a close frame and ending the socket:
| Code | Cause |
|---|---|
| 1002 | reserved bits, unknown opcode, unmasked client frame, bad control frame, bad continuation order, bad close payload or code |
| 1007 | text message that is not valid UTF-8 |
| 1009 | frame or assembled message above maxPayload, or buffered input above maxPending |
Limits default to a 1 MiB maxPayload and maxPayload + 128 KiB for maxPending (unparsed bytes held per connection). Change them with new WsRouter().withLimits(maxPayload, maxPending). Frames are rejected from the header alone, before the payload is buffered. There is no permessage-deflate, and send does not apply backpressure.
Compiling to a native binary
Subclassing a package class (WsHandler, UpgradeDispatch) from your own program only lowers when the whole graph is program code, the same rule as GenDispatch in unplugin. A node_modules dist copy fails with “overriding a method with a different signature”. Two consequences:
- Vendor the sources.
examples/ws-echo/scripts/vendor.mjscopies@chttp/routerand@chttp/wssrc/into.vendor/and points the ws import of@chttp/routerat the vendored router, soscriptc build src/main.tssees one source graph. - Wire the listener inline.
app.serve()registers no"upgrade"listener at all (any attach built inside the package would defer two SC1090 sites under npm-static compilation, for every app importing the router, upgrade or not). Attach an annotated literal listener in your own program, asexamples/ws-echo/src/main.tsdoes, or use a typed alias ofapp.upgradeHandler():
server.on("upgrade", function (req: IncomingMessage, socket: Socket, head: Buffer): void {
if (!ws.upgrade(req, socket, head)) {
socket.destroy();
}
});
scriptc coverage reports 100% static for the example after vendoring.
Status and TODO
- TODO: Autobahn testsuite (fuzzingclient, Docker) against the node and native builds. Sections 1 to 7 are the target; permessage-deflate sections (12, 13) are out of scope. Results will go in
bench/RESULTS-ws.md. - TODO: throughput cell against
wson node. - Not implemented: subprotocol and origin helpers, backpressure aware
send, idle timeouts.