@orkestrel/scaffold 0.0.66 → 0.0.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/main.js +67 -44
- package/dist/bin/main.js.map +1 -1
- package/dist/host/agents/templates/brief.md +9 -0
- package/dist/host/claude/agents/orkestrel.md +8 -8
- package/dist/host/claude/rules/names.md +15 -0
- package/dist/host/claude/rules/tests.md +33 -4
- package/dist/host/claude/rules/workspace.md +14 -2
- package/dist/host/dotfiles/prettierignore +3 -0
- package/dist/host/guides/README.md +65 -0
- package/dist/host/guides/abort.md +169 -0
- package/dist/host/guides/agent.md +1509 -0
- package/dist/host/guides/brief.md +1266 -0
- package/dist/host/guides/browser.md +2200 -0
- package/dist/host/guides/budget.md +196 -0
- package/dist/host/guides/codec.md +519 -0
- package/dist/host/guides/console.md +785 -0
- package/dist/host/guides/contract.md +1193 -0
- package/dist/host/guides/csv.md +541 -0
- package/dist/host/guides/database.md +2518 -0
- package/dist/host/guides/emitter.md +233 -0
- package/dist/host/guides/form.md +1791 -0
- package/dist/host/guides/html.md +717 -0
- package/dist/host/guides/indexeddb.md +505 -0
- package/dist/host/guides/interpret.md +1029 -0
- package/dist/host/guides/lsp.md +515 -0
- package/dist/host/guides/markdown.md +964 -0
- package/dist/host/guides/mcp.md +5554 -0
- package/dist/host/guides/middleware.md +927 -0
- package/dist/host/guides/msg.md +440 -0
- package/dist/host/guides/ndjson.md +120 -0
- package/dist/host/guides/ollama.md +380 -0
- package/dist/host/guides/pool.md +280 -0
- package/dist/host/guides/probe.md +1210 -0
- package/dist/host/guides/process.md +1620 -0
- package/dist/host/guides/program.md +1110 -0
- package/dist/host/guides/qualifier.md +854 -0
- package/dist/host/guides/queue.md +370 -0
- package/dist/host/guides/rater.md +330 -0
- package/dist/host/guides/reason.md +1122 -0
- package/dist/host/guides/relation.md +373 -0
- package/dist/host/guides/router.md +753 -0
- package/dist/host/guides/scaffold.md +192 -31
- package/dist/host/guides/sea.md +383 -0
- package/dist/host/guides/server.md +752 -0
- package/dist/host/guides/sqlite.md +330 -0
- package/dist/host/guides/sse.md +187 -0
- package/dist/host/guides/supervisor.md +4890 -0
- package/dist/host/guides/table.md +1556 -0
- package/dist/host/guides/template.md +280 -0
- package/dist/host/guides/terminal.md +1145 -0
- package/dist/host/guides/test.md +2969 -0
- package/dist/host/guides/timeout.md +252 -0
- package/dist/host/guides/tool.md +311 -0
- package/dist/host/guides/toolbox.md +1038 -0
- package/dist/host/guides/websocket.md +282 -0
- package/dist/host/guides/worker.md +615 -0
- package/dist/host/guides/workflow.md +1507 -0
- package/dist/host/guides/workspace.md +595 -0
- package/dist/host/manifest.json +1218 -10
- package/dist/host/tests/policy.test.ts +279 -2
- package/dist/host/tests/setupPolicy.ts +437 -6
- package/dist/src/core/index.cjs +44 -22
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +33 -9
- package/dist/src/core/index.d.ts +33 -9
- package/dist/src/core/index.js +43 -23
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +1750 -1567
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +106 -24
- package/dist/src/server/index.d.ts +106 -24
- package/dist/src/server/index.js +1751 -1570
- package/dist/src/server/index.js.map +1 -1
- package/package.json +9 -9
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# WebSocket
|
|
2
|
+
|
|
3
|
+
> The server-native bidirectional transport: a lean, typed wrapper over a raw upgraded
|
|
4
|
+
> `node:stream` Duplex socket that speaks only the RFC 6455 wire protocol, owning the
|
|
5
|
+
> handshake, the masked and unmasked frame codec, ping and pong, and the close handshake,
|
|
6
|
+
> and surfacing every message on an owned `emitter`.
|
|
7
|
+
|
|
8
|
+
After an HTTP server hands you an upgraded socket, this wrapper turns that raw byte stream into a typed, observable connection, and its only runtime dependency is `@orkestrel/emitter`, which supplies the typed emitter; [`node:crypto`](https://nodejs.org/api/crypto.html) supplies the one handshake hash. The wrapper has no knowledge of MCP, JSON-RPC, reconnection, heartbeats, or any message schema — those belong to a _message_ transport built one layer up, and this is only the wire. The codec and the boundary guards are pure exported functions, pinned against [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455)'s worked byte vectors and malformed-input cases, and the [`NodeWebSocket`](#nodewebsocketinterface) class is the thin stateful driver that runs them over a [`node:stream`](https://nodejs.org/api/stream.html) Duplex. Keeping the codec pure and the wrapper minimal is the lean-native-wrapper discipline: a small typed surface over native power, with the hard parts exported as testable units. Source: [`src/server`](../src/server). Surfaced through the `@src/server` barrel.
|
|
9
|
+
|
|
10
|
+
## Surface
|
|
11
|
+
|
|
12
|
+
Take the raw socket an HTTP server hands an `upgrade` listener and pass it to `createNodeWebSocket`, which writes the handshake and echoes every message it receives:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { createServer } from 'node:http'
|
|
16
|
+
import { createNodeWebSocket } from '@orkestrel/websocket'
|
|
17
|
+
|
|
18
|
+
// A node:http server hands every upgrade request a raw socket; this wrapper takes it
|
|
19
|
+
// from there. Passing the client's `sec-websocket-key` selects server mode — the
|
|
20
|
+
// wrapper writes the 101 handshake, marks the connection open, and decodes frames.
|
|
21
|
+
createServer().on('upgrade', (request, socket, head) => {
|
|
22
|
+
const key = request.headers['sec-websocket-key']
|
|
23
|
+
if (typeof key !== 'string') {
|
|
24
|
+
socket.destroy()
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
const ws = createNodeWebSocket({
|
|
28
|
+
socket,
|
|
29
|
+
key, // present => server mode + 101 handshake
|
|
30
|
+
head, // any bytes that arrived bundled with the upgrade request
|
|
31
|
+
on: { message: (text) => ws.send(`echo: ${text}`) }, // wire listeners at construction
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
ws.emitter.on('close', (code, reason) => console.log('closed', code, reason))
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`send` writes a UTF-8 text frame (unmasked, because this is the server); the peer's reply arrives back as a `message`. Everything is driven off the one `emitter` — there are no callbacks to register beyond it.
|
|
39
|
+
|
|
40
|
+
Narrow the header before the call. `sec-websocket-key` is typed `string | undefined`, and omitting `key` is what selects client mode: the wrapper writes no 101 handshake and masks its frames, so a browser waiting for the handshake sees a connection that never opens. The guard turns a missing header into a refused socket instead.
|
|
41
|
+
|
|
42
|
+
### Factories
|
|
43
|
+
|
|
44
|
+
| API | Kind | Summary |
|
|
45
|
+
| --------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
46
|
+
| `createNodeWebSocket` | function | Creates a server-native WebSocket over a raw upgraded `node:stream` Duplex socket — server mode when a `key` is given, client mode otherwise. |
|
|
47
|
+
|
|
48
|
+
### Classes
|
|
49
|
+
|
|
50
|
+
| API | Kind | Summary |
|
|
51
|
+
| --------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
52
|
+
| `NodeWebSocket` | class | Implements the wrapper contract over a raw upgraded `node:stream` Duplex socket, driving the RFC 6455 handshake, the frame codec, auto-pong, and the close handshake, and surfacing every event on an owned `emitter`. |
|
|
53
|
+
|
|
54
|
+
### Errors
|
|
55
|
+
|
|
56
|
+
| API | Kind | Summary |
|
|
57
|
+
| ------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
58
|
+
| `WebSocketError` | class | Represents an error the WebSocket wrapper throws for a refused caller-supplied value, carrying a machine-readable `code` and an optional `context`. |
|
|
59
|
+
| `isWebSocketError` | function | Checks whether a caught value is a `WebSocketError`, narrowing it so a `catch` can branch on `error.code`. |
|
|
60
|
+
|
|
61
|
+
### Codec helpers
|
|
62
|
+
|
|
63
|
+
| API | Kind | Summary |
|
|
64
|
+
| --------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| `computeWebSocketAccept` | function | Computes the `Sec-WebSocket-Accept` response value for an RFC 6455 upgrade. |
|
|
66
|
+
| `isWebSocketKey` | function | Checks whether a value is a canonical RFC 6455 `Sec-WebSocket-Key`. |
|
|
67
|
+
| `isWebSocketProtocol` | function | Checks whether a value is one valid WebSocket subprotocol token. |
|
|
68
|
+
| `parseWebSocketFrame` | function | Decodes a single RFC 6455 frame from the front of a buffer, answering `undefined` while the buffer is incomplete so the caller accumulates and retries. |
|
|
69
|
+
| `measureWebSocketFrame` | function | Reads the declared payload length off the front of a buffer without buffering or reading the payload itself, answering `undefined` until the length field is complete. |
|
|
70
|
+
| `matchesWebSocketCanonical` | function | Checks whether the next frame uses the shortest valid RFC 6455 payload-length encoding, answering `undefined` until its length prefix is complete. |
|
|
71
|
+
| `parseUTF8` | function | Decodes a byte sequence as strict UTF-8, answering `undefined` when the sequence is malformed. |
|
|
72
|
+
| `isCloseCode` | function | Checks whether a numeric value is a close status code an RFC 6455 endpoint may receive (§7.4.1). |
|
|
73
|
+
| `encodeWebSocketFrame` | function | Encodes a single RFC 6455 frame to its wire bytes — the inverse of `parseWebSocketFrame`. |
|
|
74
|
+
|
|
75
|
+
### Constants
|
|
76
|
+
|
|
77
|
+
A `Shape` cell holds the constant's declared type.
|
|
78
|
+
|
|
79
|
+
| API | Kind | Shape | Summary |
|
|
80
|
+
| ----------------------------------- | ----- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
81
|
+
| `WEBSOCKET_GUID` | const | `string` | Names the accept GUID concatenated to a client's `Sec-WebSocket-Key` before the accept hash, '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'. |
|
|
82
|
+
| `WEBSOCKET_VERSION` | const | `string` | Names the supported protocol version, '13'. |
|
|
83
|
+
| `WEBSOCKET_OPCODE_TEXT` | const | `number` | Names the text frame opcode, 0x01. |
|
|
84
|
+
| `WEBSOCKET_OPCODE_BINARY` | const | `number` | Names the binary frame opcode, 0x02. |
|
|
85
|
+
| `WEBSOCKET_OPCODE_CONTINUATION` | const | `number` | Names the continuation frame opcode, 0x00. |
|
|
86
|
+
| `WEBSOCKET_OPCODE_CLOSE` | const | `number` | Names the close frame opcode, 0x08. |
|
|
87
|
+
| `WEBSOCKET_OPCODE_PING` | const | `number` | Names the ping frame opcode, 0x09. |
|
|
88
|
+
| `WEBSOCKET_OPCODE_PONG` | const | `number` | Names the pong frame opcode, 0x0a. |
|
|
89
|
+
| `WEBSOCKET_READY_CONNECTING` | const | `WebSocketReadyState` | Names the connecting ready state, 0. |
|
|
90
|
+
| `WEBSOCKET_READY_OPEN` | const | `WebSocketReadyState` | Names the open ready state, 1. |
|
|
91
|
+
| `WEBSOCKET_READY_CLOSING` | const | `WebSocketReadyState` | Names the closing ready state, 2. |
|
|
92
|
+
| `WEBSOCKET_READY_CLOSED` | const | `WebSocketReadyState` | Names the closed ready state, 3. |
|
|
93
|
+
| `WEBSOCKET_CLOSE_NORMAL` | const | `number` | Names the normal-closure status code, 1000. |
|
|
94
|
+
| `WEBSOCKET_CLOSE_PROTOCOL` | const | `number` | Names the protocol-error status code, 1002. |
|
|
95
|
+
| `WEBSOCKET_CLOSE_UNSUPPORTED` | const | `number` | Names the unsupported-data status code, 1003. |
|
|
96
|
+
| `WEBSOCKET_CLOSE_INVALID` | const | `number` | Names the invalid-frame-payload-data status code, 1007. |
|
|
97
|
+
| `WEBSOCKET_CLOSE_TOO_BIG` | const | `number` | Names the message-too-big status code, 1009. |
|
|
98
|
+
| `WEBSOCKET_MAX_PAYLOAD` | const | `number` | Names the default cap on both an inbound frame's declared length and a reassembled message's total byte count, 104,857,600 bytes (100 MiB). |
|
|
99
|
+
| `WEBSOCKET_CLOSE_TIMEOUT_MS` | const | `number` | Names the default close-handshake timeout, 30,000 milliseconds — how long `close` waits for the peer's echo. |
|
|
100
|
+
| `WEBSOCKET_CONTROL_MAX_LENGTH` | const | `number` | Names the maximum control-frame payload length, 125 bytes. |
|
|
101
|
+
| `WEBSOCKET_CLOSE_REASON_MAX_LENGTH` | const | `number` | Names the maximum UTF-8 close-reason length after the two-byte status code, 123. |
|
|
102
|
+
| `WEBSOCKET_FAIL_TIMEOUT_MS` | const | `number` | Names the flush grace, 1,000 milliseconds, a validation-breach close frame is given before the hard teardown fallback destroys the socket. |
|
|
103
|
+
|
|
104
|
+
### Types
|
|
105
|
+
|
|
106
|
+
A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`.
|
|
107
|
+
|
|
108
|
+
| API | Kind | Shape | Summary |
|
|
109
|
+
| ------------------------ | --------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
110
|
+
| `WebSocketReadyState` | type | `0 \| 1 \| 2 \| 3` | Represents a WebSocket ready state — the stage a connection has reached between the handshake and the socket's end. |
|
|
111
|
+
| `WebSocketFrame` | interface | `{ fin, opcode, payload, consumed, masked, rsv }` | Represents a parsed RFC 6455 frame — the structured result of decoding one frame off the wire. |
|
|
112
|
+
| `WebSocketEncodeOptions` | interface | `{ masked?, mask? }` | Represents the options for `encodeWebSocketFrame` — how a frame is masked on the wire. |
|
|
113
|
+
| `WebSocketErrorCode` | type | `'OPTION' \| 'LIMIT' \| 'CLOSE' \| 'FRAME'` | Represents the subject a `WebSocketError` names as refused. |
|
|
114
|
+
| `NodeWebSocketEventMap` | type | `{ open, message, close, error, ping, pong }` | Represents the event map a `NodeWebSocketInterface` emitter carries. |
|
|
115
|
+
| `NodeWebSocketOptions` | interface | `{ socket, key?, head?, protocol?, on?, error?, payload?, timeout?, signal? }` | Represents the options for `createNodeWebSocket` — the upgraded `socket`, the `key` that selects server or client mode, and the listeners, caps, and cancellation signal the wrapper runs under. |
|
|
116
|
+
| `NodeWebSocketInterface` | interface | `{ emitter, readyState } plus send, ping, close, destroy` | Represents the behavioral contract a server-native WebSocket exposes over a raw upgraded socket. |
|
|
117
|
+
|
|
118
|
+
Frame payloads are raw `Buffer`s off the wire; a text frame decodes to a `string` at the boundary, and the untyped socket `data` chunk is narrowed to a `Buffer` with a guard, never an assertion.
|
|
119
|
+
|
|
120
|
+
## Methods
|
|
121
|
+
|
|
122
|
+
The public methods of the behavioral interface — its `readonly` data members `emitter` and `readyState` stay in the preceding Surface row. `NodeWebSocket` implements `NodeWebSocketInterface` exactly, so this doubles as the per-instance method surface.
|
|
123
|
+
|
|
124
|
+
#### `NodeWebSocketInterface`
|
|
125
|
+
|
|
126
|
+
| Method | Returns | Summary |
|
|
127
|
+
| --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
128
|
+
| `send` | `void` | Writes a message as a UTF-8 text frame, masked in client mode and unmasked in server mode, and does nothing unless `readyState` is open. |
|
|
129
|
+
| `ping` | `void` | Writes a ping frame with an optional payload, which the peer answers with a pong, and does nothing unless `readyState` is open. |
|
|
130
|
+
| `close` | `void` | Starts the closing handshake: moves to the closing ready state, writes a close frame carrying the two-byte big-endian `code` and an optional `reason`, and ends the writable side. |
|
|
131
|
+
| `destroy` | `void` | Tears the socket down immediately: detaches the wrapper's domain socket listeners, destroys the socket, emits a final `close`, and tears the emitter down. |
|
|
132
|
+
|
|
133
|
+
## Contract
|
|
134
|
+
|
|
135
|
+
These invariants hold across `src/server` ↔ `websocket.md`:
|
|
136
|
+
|
|
137
|
+
1. **DOC ↔ SOURCE bijection.** Every row in the `## Surface` tables is a real export of the module, and every export appears as a Surface row — exhaustive, both directions.
|
|
138
|
+
2. **Wire-only, schema-agnostic.** The wrapper speaks the RFC 6455 frame protocol and nothing else — no MCP, no JSON-RPC, no message schema. A higher transport is built _on_ it, keeping this interface minimal.
|
|
139
|
+
3. **The codec and boundary guards are pure and exhaustively pinned.** The helpers are tested against RFC 6455's worked vectors, malformed handshake values, non-canonical length encodings, truncation at every byte, and seeded round trips. `parseWebSocketFrame` returns `undefined` on an **incomplete** buffer (the caller accumulates across `data` chunks); `encode` and `parse` are exact inverses for valid frames.
|
|
140
|
+
4. **Server vs. client is the single `key` decision.** A canonical 16-byte-base64 `key` (the client's `Sec-WebSocket-Key`) selects server mode: the wrapper writes the `101 Switching Protocols` handshake with `Sec-WebSocket-Accept: computeWebSocketAccept(key)` and sends **unmasked** frames. No `key` is client mode: no handshake is written and every outgoing frame is **masked** — RFC 6455 §5.3 mandates client→server masking. A negotiated `protocol` is accepted only in server mode and must pass `isWebSocketProtocol`; a malformed constructor option throws an `OPTION`-coded `WebSocketError` before the wrapper writes to or assumes ownership of the socket.
|
|
141
|
+
5. **One accumulation buffer, drained frame by frame.** Incoming `data` chunks append to a buffer that is decoded with `parseWebSocketFrame` in a loop, slicing each frame's `consumed` bytes off the front and re-parsing until a partial frame remains. Every iteration independently checks canonical encoding and the declared payload cap, including the second and later frames in one chunk. Dispatch by opcode: a data frame (text, binary, or `WEBSOCKET_OPCODE_CONTINUATION`) buffers its fragments and emits one `message` (decoded UTF-8) at `fin`; a ping emits `ping` and is **auto-answered with a pong**; a pong emits `pong`; a close is echoed back (RFC 6455 §5.5.1), ends the socket, and emits the final `close`.
|
|
142
|
+
6. **Observable, and a faulty listener can never sink the socket.** The wrapper exposes a typed `emitter` it owns by composition; listener isolation is the emitter's job. The error channels stay distinct: an underlying socket fault emits the map's domain `error` event and terminates the wrapper, whereas a listener that _throws_ is caught by the emitter and routed to its own `error` handler (the `error` constructor option), never re-entered as a domain event. Every terminal path detaches only the wrapper's domain `data` / `close` / `error` listeners and leaves one durable no-op socket `error` sink, so a late peer RST cannot become an uncaught Node exception; caller-owned listeners remain untouched.
|
|
143
|
+
7. **A malformed or over-limit peer fails the connection, never the process.** `matchesWebSocketCanonical` rejects non-minimal extended lengths and a set 64-bit high bit with `WEBSOCKET_CLOSE_PROTOCOL`; `measureWebSocketFrame` rejects each frame whose declared length exceeds `payload` (default `WEBSOCKET_MAX_PAYLOAD`) before its bytes are buffered, and the same cap applies to a reassembled fragmented message's total size — either cap breach closes `WEBSOCKET_CLOSE_TOO_BIG`. A text payload that fails `parseUTF8` closes `WEBSOCKET_CLOSE_INVALID`; a received close code that fails `isCloseCode` closes `WEBSOCKET_CLOSE_PROTOCOL`; a fragmented or oversized control frame, nonzero `rsv`, reserved opcode, or wrong mask direction also closes `WEBSOCKET_CLOSE_PROTOCOL`. `close()` uses a configurable timeout so a silent peer cannot leak the handle open. Validation failures flush their close frame before the hard-teardown fallback destroys the socket.
|
|
144
|
+
8. **An `AbortSignal` is an external cancellation seam.** `signal` (composing with `@orkestrel/abort` / `@orkestrel/timeout`'s native `AbortSignal`s) tears the socket down through `destroy()` on abort — immediately after construction if already aborted, otherwise on the signal's `abort` event. The listener is removed on every terminal path (`#finish` and `destroy`) so a long-lived, shared signal never accumulates listeners from closed sockets.
|
|
145
|
+
|
|
146
|
+
## Errors
|
|
147
|
+
|
|
148
|
+
`WebSocketError` is the one failure type, carrying a stable machine-readable `code`. Narrow a caught value with `isWebSocketError`, then branch on `code`.
|
|
149
|
+
|
|
150
|
+
| Code | Raised when |
|
|
151
|
+
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
152
|
+
| `OPTION` | `createNodeWebSocket` refused a `NodeWebSocketOptions` member: `payload`, `timeout`, `key`, `protocol`, or a `protocol` given without a server `key`. |
|
|
153
|
+
| `LIMIT` | An outbound control-frame payload exceeded its RFC 6455 §5.5 cap: a `ping` payload past `WEBSOCKET_CONTROL_MAX_LENGTH`, or a `close` reason past `WEBSOCKET_CLOSE_REASON_MAX_LENGTH`. |
|
|
154
|
+
| `CLOSE` | `close` received a status code `isCloseCode` refuses. |
|
|
155
|
+
| `FRAME` | `encodeWebSocketFrame` refused a frame-header argument: an opcode outside the four-bit wire field, a `mask` that is not 4 bytes, or a `mask` without `masked: true`. |
|
|
156
|
+
|
|
157
|
+
Every refusal is a caller-supplied value the wire protocol cannot carry, and each throws before it writes a byte: an `OPTION` throws before the wrapper writes to or assumes ownership of the socket, a `LIMIT` and a `CLOSE` throw without writing a frame or changing `readyState`, and a `FRAME` throws out of the pure encoder, which touches no socket at all. A **peer's** protocol violation is not a `WebSocketError`: it closes the connection with the matching `WEBSOCKET_CLOSE_*` status code and emits `close`, per the preceding Contract invariant.
|
|
158
|
+
|
|
159
|
+
`context` carries the refused value under a key naming it — the offending option for an `OPTION`, `size` and the `limit` it exceeded for a `LIMIT`, the refused `code` for a `CLOSE`, and `opcode` or the mask's `size` for a `FRAME`. A `mask` supplied without `masked: true` carries no `context`; the message names the fault.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { createNodeWebSocket, isWebSocketError } from '@orkestrel/websocket'
|
|
163
|
+
|
|
164
|
+
server.on('upgrade', (request, socket, head) => {
|
|
165
|
+
const key = request.headers['sec-websocket-key']
|
|
166
|
+
if (typeof key !== 'string') {
|
|
167
|
+
socket.destroy()
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
createNodeWebSocket({ socket, key, head })
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (isWebSocketError(error) && error.code === 'OPTION') {
|
|
174
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n')
|
|
175
|
+
socket.destroy()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Patterns
|
|
182
|
+
|
|
183
|
+
### Accept an upgrade and echo messages (server mode)
|
|
184
|
+
|
|
185
|
+
The handle is fully driven through its `emitter` — attach as many observers as you like; a throw in one is isolated and never reaches the socket.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { createNodeWebSocket } from '@orkestrel/websocket'
|
|
189
|
+
|
|
190
|
+
server.on('upgrade', (request, socket, head) => {
|
|
191
|
+
const key = request.headers['sec-websocket-key']
|
|
192
|
+
if (typeof key !== 'string') {
|
|
193
|
+
socket.destroy()
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
const ws = createNodeWebSocket({
|
|
197
|
+
socket,
|
|
198
|
+
key,
|
|
199
|
+
head, // any bytes already buffered after the upgrade headers
|
|
200
|
+
on: { message: (text) => ws.send(`echo: ${text}`) }, // wired before the first frame arrives
|
|
201
|
+
})
|
|
202
|
+
ws.emitter.on('message', (text) => log('echoed', text)) // a second observer of the same event
|
|
203
|
+
ws.emitter.on('close', (code, reason) => log('closed', code, reason))
|
|
204
|
+
})
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Stream-decode frames across chunk boundaries
|
|
208
|
+
|
|
209
|
+
Accumulate incoming bytes into one buffer and loop `parseWebSocketFrame` over it, slicing off each complete frame until an incomplete one remains:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import { parseWebSocketFrame } from '@orkestrel/websocket'
|
|
213
|
+
|
|
214
|
+
let buffer = Buffer.alloc(0)
|
|
215
|
+
socket.on('data', (chunk: Buffer) => {
|
|
216
|
+
buffer = Buffer.concat([buffer, chunk])
|
|
217
|
+
for (;;) {
|
|
218
|
+
const frame = parseWebSocketFrame(buffer)
|
|
219
|
+
if (frame === undefined) break // incomplete — wait for more bytes
|
|
220
|
+
buffer = buffer.subarray(frame.consumed) // slice the frame off, re-parse the rest
|
|
221
|
+
handle(frame)
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Encode a frame to the wire (server unmasked, client masked)
|
|
227
|
+
|
|
228
|
+
Encode the same text payload twice, once as a server frame and once as a masked client frame:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { encodeWebSocketFrame, WEBSOCKET_OPCODE_TEXT } from '@orkestrel/websocket'
|
|
232
|
+
|
|
233
|
+
socket.write(encodeWebSocketFrame(WEBSOCKET_OPCODE_TEXT, 'hello')) // server→client (unmasked)
|
|
234
|
+
socket.write(encodeWebSocketFrame(WEBSOCKET_OPCODE_TEXT, 'hello', { masked: true })) // client→server
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Compute the handshake accept token
|
|
238
|
+
|
|
239
|
+
Compute the `Sec-WebSocket-Accept` value RFC 6455 §1.3 works through as its own example:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
import { computeWebSocketAccept } from '@orkestrel/websocket'
|
|
243
|
+
|
|
244
|
+
computeWebSocketAccept('dGhlIHNhbXBsZSBub25jZQ==') // 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=' (RFC 6455 §1.3)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Keep a connection alive, and tear it down on demand
|
|
248
|
+
|
|
249
|
+
Ping the peer on an interval, clear the timer when the connection closes, and destroy the socket immediately on a fatal error:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
import { createNodeWebSocket } from '@orkestrel/websocket'
|
|
253
|
+
|
|
254
|
+
const ws = createNodeWebSocket({ socket })
|
|
255
|
+
ws.emitter.on('pong', () => console.log('peer is alive'))
|
|
256
|
+
|
|
257
|
+
const heartbeat = setInterval(() => ws.ping(), 30_000) // liveness probe; answered by an auto-pong
|
|
258
|
+
ws.emitter.on('close', () => clearInterval(heartbeat))
|
|
259
|
+
|
|
260
|
+
// Later, or on a fatal error — abort immediately without a close handshake:
|
|
261
|
+
ws.destroy()
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Practices
|
|
265
|
+
|
|
266
|
+
- **Reach for a message transport, not raw frames, when you have a protocol.** This is the wire-level handle a higher-level message transport is built on; drop to it directly only for bespoke framing where no schema applies. If you find yourself hand-rolling request/response correlation on top, you want the layer that sits over this one.
|
|
267
|
+
- **Let the mode handle masking — never set the mask bit yourself.** Server mode sends unmasked, client mode masks; the single `key` choice decides it. Reach for `encodeWebSocketFrame(..., { masked: true })` only when you are feeding the parser a synthetic client frame, for example in a test.
|
|
268
|
+
- **Drive the parser as a stream, never per-chunk.** Accumulate `data`, loop `parseWebSocketFrame`, slice `consumed` off, and treat `undefined` as "need more bytes". A frame can span chunks and a chunk can hold several frames — the buffer is what reconciles both.
|
|
269
|
+
- **Observe everything through the `emitter`.** Wire `message` / `close` / `ping` / `pong` and the domain `error`; a listener that throws is contained by the emitter and surfaced on its own `error` handler (the `error` option), so one bad observer never takes the connection down.
|
|
270
|
+
|
|
271
|
+
## Tests
|
|
272
|
+
|
|
273
|
+
- [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ `src/server` bijection, the `## Methods` ↔ interface/class method parity, and the equality gate: every `Summary` cell against its declaration's description paragraph, the titled `Accept an upgrade and echo messages (server mode)` fence against the `@example` block of that title (pinned so the titled pair cannot be retired silently), and the README pitch against this guide's tagline. It also runs the flagship fences and asserts the values their comments claim.
|
|
274
|
+
- [`tests/src/server/helpers.test.ts`](../tests/src/server/helpers.test.ts) — the RFC 6455 codec helpers and boundary predicates as pure units against the spec's own byte vectors: the §1.3 handshake accept token, the unmasked + masked "Hello" frame encoding (§5.7), the 7/16/64-bit length-form boundaries (125 / 126 / 65 536), `measureWebSocketFrame` reading the declared length off the header alone, `matchesWebSocketCanonical`'s §5.2 minimal-length-encoding check (each shortest form accepted, an incomplete length prefix answered `undefined`, a non-minimal extended length or a set 64-bit high bit rejected), `isWebSocketKey` and `isWebSocketProtocol` against canonical and malformed handshake values, and `isCloseCode` classifying every receivable and rejected close code.
|
|
275
|
+
- [`tests/src/server/parsers.test.ts`](../tests/src/server/parsers.test.ts) — the RFC 6455 coercers as pure units: `parseWebSocketFrame` against the spec's own byte vectors (the control opcodes, an incomplete buffer → `undefined` split mid-header/mid-mask/mid-payload, trailing-byte recovery through `consumed`, the encode↔parse inverse), and `parseUTF8` against valid and malformed UTF-8 sequences.
|
|
276
|
+
- [`tests/src/server/NodeWebSocket.test.ts`](../tests/src/server/NodeWebSocket.test.ts) — the wrapper driven end to end over an in-memory `node:stream` Duplex pair (a cross-wired `PassThrough` at each end — a real bidirectional socket, no mock): the 101 handshake (with subprotocol echo), a masked client text frame → `message`, continuation-fragment reassembly, two frames in one chunk, `send` → an unmasked readable frame, ping → auto-pong, the close handshake + `close` event, `destroy` idempotency, and observer-error isolation.
|
|
277
|
+
- [`tests/integration.test.ts`](../tests/integration.test.ts) — the public factory driven by native `WebSocket` clients against a real Node HTTP upgrade server: handshake, multibyte and 2 MB payloads, binary rejection, client/server closes, ordered bursts, concurrency, churn, and reconnect.
|
|
278
|
+
|
|
279
|
+
## See also
|
|
280
|
+
|
|
281
|
+
- [`AGENTS.md`](../AGENTS.md) — the coding rules this package follows.
|
|
282
|
+
- [`README.md`](README.md) — the guides index.
|