quico 0.4.0 → 0.8.0

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.
@@ -0,0 +1,815 @@
1
+ /*
2
+ * quico: a full HTTP stack in JavaScript
3
+ * Copyright 2025 colocohen
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at:
8
+ *
9
+ * https://github.com/colocohen/quico
10
+ */
11
+
12
+ import { Blob } from 'node:buffer';
13
+
14
+ import {
15
+ WSParser, encodeFrame, acceptKey,
16
+ OPCODE, CLOSE,
17
+ installEventSurfaces, emitBoth, nodeMessagePayload,
18
+ userCloseCode, closeReasonBytes, sendInOrder, queueCloseFrame
19
+ } from './ws.js';
20
+ import { isToken, STATUS_TEXT } from './http_common.js';
21
+
22
+
23
+ // ============================================================
24
+ // WebSocket server.
25
+ //
26
+ // The connection object handed to the application is the SAME WebSocket the
27
+ // browser defines and the same one ws_client.js produces — same events, same
28
+ // readyState, same close semantics, same binaryType. A function written to
29
+ // handle a socket works unchanged on either side, which is the whole reason
30
+ // for choosing the browser interface over the `ws` package's shape.
31
+ //
32
+ // What has no browser equivalent is *accepting* a connection, because a
33
+ // browser never does. That part is modelled on Deno and Cloudflare Workers,
34
+ // which return a standard WebSocket from a server-side upgrade — and on the
35
+ // `ws` package for the handshake validation itself, which is worth copying
36
+ // precisely: it answers a bad handshake with a real HTTP response rather than
37
+ // dropping the socket, so a client learns WHY it was refused instead of
38
+ // seeing a connection reset.
39
+ // ============================================================
40
+
41
+ var CONNECTING = 0, OPEN = 1, CLOSING = 2, CLOSED = 3;
42
+ var CLOSE_TIMEOUT = 30000;
43
+
44
+ // A Sec-WebSocket-Key is 16 random bytes in base64 — always 24 characters
45
+ // ending in '=='. Checking the shape (as `ws` does) catches a client that is
46
+ // not really speaking WebSocket before any state is built for it.
47
+ var KEY_RE = /^[+/0-9A-Za-z]{22}==$/;
48
+
49
+ var CloseEventCtor = typeof CloseEvent !== 'undefined' ? CloseEvent : function (type, init) {
50
+ init = init || {};
51
+ var ev = new Event(type, init);
52
+ Object.defineProperties(ev, {
53
+ code: { value: init.code === undefined ? 0 : init.code, enumerable: true },
54
+ reason: { value: init.reason === undefined ? '' : init.reason, enumerable: true },
55
+ wasClean: { value: !!init.wasClean, enumerable: true }
56
+ });
57
+ return ev;
58
+ };
59
+
60
+
61
+ // ============================================================
62
+ // ServerWebSocket — the WHATWG interface, server side
63
+ // ============================================================
64
+
65
+ class ServerWebSocket extends EventTarget {
66
+
67
+ static get CONNECTING() { return CONNECTING; }
68
+ static get OPEN() { return OPEN; }
69
+ static get CLOSING() { return CLOSING; }
70
+ static get CLOSED() { return CLOSED; }
71
+
72
+ constructor(socket, options) {
73
+ super();
74
+ options = options || {};
75
+
76
+ this._socket = socket;
77
+ this._readyState = OPEN; // the handshake is already complete
78
+ this._bufferedAmount = 0;
79
+ this._binaryType = 'blob'; // the browser's default
80
+ this._protocol = options.protocol || '';
81
+ this._extensions = '';
82
+ this._url = options.url || '';
83
+ try { this._origin = new URL(this._url).origin; } catch (e) { this._origin = ''; }
84
+
85
+ this._closeSent = false;
86
+ this._closeReceived = false;
87
+ this._closeCode = CLOSE.ABNORMAL;
88
+ this._closeReason = '';
89
+ this._closeTimer = null;
90
+
91
+ this._onopen = null; this._onmessage = null;
92
+ this._onerror = null; this._onclose = null; this._onpong = null;
93
+
94
+ // Adds .on/.once/.off and the _emitNode helper. See ws.js (installEventSurfaces) for why
95
+ // the two surfaces receive different arguments for the same event.
96
+ installEventSurfaces(this);
97
+
98
+ var self = this;
99
+ // isServer: true — incoming frames MUST be masked and outgoing ones must
100
+ // NOT be. The parser enforces the direction; getting it backwards is a
101
+ // protocol error the peer will close on.
102
+ var parser = new WSParser({ isServer: true, maxPayload: options.maxPayload });
103
+ this._parser = parser;
104
+
105
+ parser.on('message', function (data, isBinary) {
106
+ if (self._readyState !== OPEN) return;
107
+ emitBoth(self, 'message',
108
+ new MessageEvent('message', {
109
+ data: isBinary ? self._toBinary(data) : data,
110
+ // The URL's origin, as the client's (not the whole URL).
111
+ origin: self._origin
112
+ }),
113
+ // The ws surface gets the raw payload, NOT self._toBinary(data): `ws`
114
+ // has no binaryType and its handlers always receive a Buffer.
115
+ [nodeMessagePayload(data, isBinary), isBinary]);
116
+ });
117
+
118
+ parser.on('ping', function (payload) {
119
+ // Answered automatically, as a browser does — but `ws` DOES surface
120
+ // pings to its listeners, so the ws-side event fires. The WHATWG side
121
+ // has no 'ping' event at all, which is why this one is emitted on that
122
+ // surface only.
123
+ if (self._readyState === OPEN) self._sendFrame(OPCODE.PONG, payload);
124
+ self._emitNode('ping', [payload]);
125
+ });
126
+
127
+ parser.on('pong', function (payload) {
128
+ self._pongSeen = true;
129
+ emitBoth(self, 'pong', new MessageEvent('pong', { data: payload }), [payload]);
130
+ });
131
+
132
+ parser.on('close', function (code, reason) {
133
+ self._closeReceived = true;
134
+ self._closeCode = code;
135
+ // Only take the peer's reason when we did not supply one. When WE
136
+ // started the handshake, the reason belongs to us: a peer that echoes an
137
+ // empty reason (or a different one) must not overwrite what the
138
+ // application asked to report.
139
+ if (!self._closeSent || !self._closeReason) self._closeReason = reason;
140
+ if (!self._closeSent) {
141
+ // Echo the peer's code AND reason back (RFC 6455 §5.5.1 allows any
142
+ // reason; `ws` echoes the one it received, and matching it matters:
143
+ // the peer reports what comes back in ITS close event, so replying
144
+ // with an empty reason silently erases the reason the peer just gave).
145
+ self._readyState = CLOSING;
146
+ self._sendCloseFrame(code === CLOSE.NO_STATUS ? undefined : code,
147
+ Buffer.from(reason || '', 'utf8'), true);
148
+ }
149
+ self._teardown(true);
150
+ });
151
+
152
+ parser.on('error', function (err) {
153
+ self._sendCloseFrame(err.closeCode || CLOSE.PROTOCOL_ERROR, Buffer.alloc(0));
154
+ self._closeCode = err.closeCode || CLOSE.PROTOCOL_ERROR;
155
+ self._fail(err);
156
+ });
157
+
158
+ socket.on('data', function (chunk) { parser.execute(chunk); });
159
+ socket.on('error', function (err) { self._fail(err); });
160
+ socket.on('close', function () { self._teardown(self._closeSent && self._closeReceived); });
161
+ socket.on('end', function () { self._teardown(self._closeSent && self._closeReceived); });
162
+
163
+ if (socket.setNoDelay) socket.setNoDelay(true);
164
+
165
+ // Frames that shared a TCP segment with the handshake request. A client
166
+ // may legitimately send its first frame immediately after the request, and
167
+ // dropping these loses it — a bug that only appears under load, when
168
+ // segments happen to coalesce.
169
+ //
170
+ // Deferred, and that is not a detail. The application attaches onmessage
171
+ // from the connection handler, which cannot run until this constructor
172
+ // returns — so feeding the head here would emit a 'message' with nobody
173
+ // listening and the frame would be silently swallowed. The failure looks
174
+ // exactly like the bug this code exists to prevent: the first frame goes
175
+ // missing, only when segments coalesce.
176
+ // A server-side socket is open the moment it exists, but `ws` code written
177
+ // for the client side still waits for 'open'. Fire it once, asynchronously,
178
+ // so a handler attached right after construction sees it.
179
+ queueMicrotask(function () {
180
+ if (self._readyState === OPEN) emitBoth(self, 'open', new Event('open'), []);
181
+ });
182
+
183
+ if (options.head && options.head.length) {
184
+ var head = options.head;
185
+ queueMicrotask(function () {
186
+ if (self._readyState === OPEN) parser.execute(head);
187
+ });
188
+ }
189
+ }
190
+
191
+ get url() { return this._url; }
192
+ get readyState() { return this._readyState; }
193
+ get bufferedAmount() { return this._bufferedAmount; }
194
+ get protocol() { return this._protocol; }
195
+ get extensions() { return this._extensions; }
196
+
197
+ get CONNECTING() { return CONNECTING; }
198
+ get OPEN() { return OPEN; }
199
+ get CLOSING() { return CLOSING; }
200
+ get CLOSED() { return CLOSED; }
201
+
202
+ get binaryType() { return this._binaryType; }
203
+ set binaryType(v) {
204
+ if (v !== 'blob' && v !== 'arraybuffer' && v !== 'nodebuffer') return;
205
+ this._binaryType = v;
206
+ }
207
+
208
+ get onopen() { return this._onopen; }
209
+ set onopen(fn) { this._setHandler('open', '_onopen', fn); }
210
+ get onmessage() { return this._onmessage; }
211
+ set onmessage(fn) { this._setHandler('message', '_onmessage', fn); }
212
+ get onerror() { return this._onerror; }
213
+ set onerror(fn) { this._setHandler('error', '_onerror', fn); }
214
+ get onclose() { return this._onclose; }
215
+ set onclose(fn) { this._setHandler('close', '_onclose', fn); }
216
+ /** Not in the browser — see ping() below. */
217
+ get onpong() { return this._onpong; }
218
+ set onpong(fn) { this._setHandler('pong', '_onpong', fn); }
219
+
220
+ _setHandler(type, slot, fn) {
221
+ if (this[slot]) this.removeEventListener(type, this[slot]);
222
+ this[slot] = typeof fn === 'function' ? fn : null;
223
+ if (this[slot]) this.addEventListener(type, this[slot]);
224
+ }
225
+
226
+ /**
227
+ * send(data[, options])
228
+ *
229
+ * `options` is the ws extension, not WHATWG — a browser's send() takes one
230
+ * argument and infers text-vs-binary from the type. That inference is wrong
231
+ * for the ws surface, where every message arrives as a Buffer: echoing one
232
+ * back would turn a text message into a binary one, and the peer would
233
+ * report isBinary: true for something the sender called text.
234
+ *
235
+ * So `{ binary: false }` forces a Buffer to go out as a text frame, and
236
+ * `{ binary: true }` forces a string to go out as binary. Omitted, the
237
+ * browser's inference applies and browser code is unaffected.
238
+ */
239
+ send(data, options) {
240
+ if (this._readyState === CONNECTING) {
241
+ throw new DOMException('Still in CONNECTING state.', 'InvalidStateError');
242
+ }
243
+ var forced = options && typeof options.binary === 'boolean' ? options.binary : null;
244
+ var opcode, payload;
245
+ if (typeof data === 'string') { opcode = forced === true ? OPCODE.BINARY : OPCODE.TEXT; payload = Buffer.from(data, 'utf8'); }
246
+ else if (data instanceof Blob) { opcode = forced === false ? OPCODE.TEXT : OPCODE.BINARY; payload = data; }
247
+ else if (ArrayBuffer.isView(data)) { opcode = forced === false ? OPCODE.TEXT : OPCODE.BINARY; payload = Buffer.from(data.buffer, data.byteOffset, data.byteLength); }
248
+ else if (data instanceof ArrayBuffer) { opcode = forced === false ? OPCODE.TEXT : OPCODE.BINARY; payload = Buffer.from(data); }
249
+ else { opcode = OPCODE.TEXT; payload = Buffer.from(String(data), 'utf8'); }
250
+ if (this._readyState !== OPEN) {
251
+ this._bufferedAmount += payload instanceof Blob ? payload.size : payload.length; // as the client's
252
+ return;
253
+ }
254
+ // In the order sent, a Blob included (ws.js sendInOrder): a Blob was
255
+ // sent whenever it had been read, after messages sent later.
256
+ sendInOrder(this, opcode, payload);
257
+ }
258
+
259
+ /**
260
+ * Not in the browser, and a server cannot do without it.
261
+ *
262
+ * A browser answers pings itself and its connections are supervised by the
263
+ * page lifecycle. A server holding thousands of long-lived sockets has no
264
+ * such supervision: a peer that vanishes without a FIN — a laptop lid, a NAT
265
+ * timeout — leaves a socket that looks perfectly healthy for many minutes.
266
+ * A ping with no pong is the only way to notice.
267
+ */
268
+ ping(data) {
269
+ if (this._readyState !== OPEN) return;
270
+ this._pongSeen = false;
271
+ this._sendFrame(OPCODE.PING, data == null ? Buffer.alloc(0)
272
+ : Buffer.isBuffer(data) ? data : Buffer.from(data));
273
+ }
274
+
275
+ _sendFrame(opcode, payload, cb) {
276
+ if (!this._socket || this._socket.destroyed) { if (cb) cb(new Error('the WebSocket is closed')); return; }
277
+ var frame;
278
+ // mask: false — a SERVER must never mask (RFC 6455 §5.1). This is the one
279
+ // line that differs from the client, and a client will close the
280
+ // connection with 1002 if it is wrong.
281
+ try { frame = encodeFrame(opcode, payload, { mask: false }); }
282
+ catch (e) { if (cb) cb(e); this._fail(e); return; }
283
+
284
+ var self = this;
285
+ if (opcode < 0x8) this._bufferedAmount += payload.length;
286
+ this._socket.write(frame, function (err) {
287
+ if (opcode < 0x8) self._bufferedAmount -= payload.length;
288
+ if (cb) cb(err || null);
289
+ });
290
+ }
291
+
292
+ close(code, reason) {
293
+ // As the client's (ws.js): a code given, null too, is clamped and checked.
294
+ if (code !== undefined) code = userCloseCode(code);
295
+ var reasonBuf = reason !== undefined ? closeReasonBytes(reason) : Buffer.alloc(0);
296
+ if (this._readyState === CLOSING || this._readyState === CLOSED) return;
297
+
298
+ this._readyState = CLOSING;
299
+ this._closeCode = code === undefined ? CLOSE.NORMAL : code;
300
+ // As it goes on the wire: a lone surrogate is U+FFFD there (USVString).
301
+ this._closeReason = reasonBuf.toString('utf8');
302
+ this._sendCloseFrame(code === undefined ? undefined : code, reasonBuf);
303
+
304
+ var self = this;
305
+ this._closeTimer = setTimeout(function () { self._teardown(false); }, CLOSE_TIMEOUT);
306
+ if (this._closeTimer.unref) this._closeTimer.unref();
307
+ }
308
+
309
+ // `now`: answering the peer's close — what is still queued (a Blob being
310
+ // read) is dropped, the connection is over. Otherwise the close frame goes
311
+ // after the messages sent before close(), never ahead of them: a Blob still
312
+ // being read went out after the close frame, which a peer takes as a
313
+ // protocol error.
314
+ _sendCloseFrame(code, reasonBuf, now) {
315
+ if (this._closeSent) return;
316
+ this._closeSent = true;
317
+ var payload;
318
+ if (code === undefined) payload = Buffer.alloc(0);
319
+ else {
320
+ payload = Buffer.alloc(2 + (reasonBuf ? reasonBuf.length : 0));
321
+ payload.writeUInt16BE(code, 0);
322
+ if (reasonBuf && reasonBuf.length) reasonBuf.copy(payload, 2);
323
+ }
324
+ if (queueCloseFrame(this, payload, now)) return;
325
+ if (this._socket && !this._socket.destroyed) {
326
+ try { this._socket.write(encodeFrame(OPCODE.CLOSE, payload, { mask: false })); } catch (e) {}
327
+ }
328
+ }
329
+
330
+ _toBinary(buf) {
331
+ if (this._binaryType === 'nodebuffer') return buf;
332
+ if (this._binaryType === 'arraybuffer') {
333
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
334
+ }
335
+ return new Blob([buf]);
336
+ }
337
+
338
+ _fail(err) {
339
+ if (this._readyState === CLOSED) return;
340
+ var event = new Event('error');
341
+ try { Object.defineProperty(event, 'error', { value: err, enumerable: true }); } catch (e) {}
342
+ try { Object.defineProperty(event, 'message', { value: err && err.message, enumerable: true }); } catch (e) {}
343
+ // WHATWG gets an Event; ws gets the Error itself.
344
+ emitBoth(this, 'error', event, [err]);
345
+ this._teardown(false);
346
+ }
347
+
348
+ _teardown(wasClean) {
349
+ if (this._readyState === CLOSED) return;
350
+ this._readyState = CLOSED;
351
+ if (this._closeTimer) { clearTimeout(this._closeTimer); this._closeTimer = null; }
352
+ if (this._socket) {
353
+ var s = this._socket;
354
+ this._socket = null;
355
+ try { s.removeAllListeners('data'); } catch (e) {}
356
+ try { s.destroy(); } catch (e) {}
357
+ }
358
+ var code = wasClean ? this._closeCode : (this._closeReceived ? this._closeCode : CLOSE.ABNORMAL);
359
+ var reason = this._closeReason || '';
360
+ emitBoth(this, 'close',
361
+ new CloseEventCtor('close', { code: code, reason: reason, wasClean: !!wasClean }),
362
+ // `ws` passes the reason as a Buffer, not a string. Kept as it is:
363
+ // handlers written against ws call .toString() on it.
364
+ [code, Buffer.from(reason, 'utf8')]);
365
+ }
366
+ }
367
+
368
+
369
+ // ============================================================
370
+ // Handshake
371
+ // ============================================================
372
+
373
+ /**
374
+ * Refuse an upgrade with a real HTTP response.
375
+ *
376
+ * Worth doing rather than destroying the socket: a client that gets a 400 with
377
+ * a reason can be fixed, while one that gets a connection reset cannot tell a
378
+ * rejected handshake from a network fault. Modelled on how the `ws` package
379
+ * handles this.
380
+ */
381
+ function abortHandshake(socket, code, message, extraHeaders) {
382
+ message = message || STATUS_TEXT[code] || '';
383
+ var headers = {
384
+ 'Connection': 'close',
385
+ 'Content-Type': 'text/plain',
386
+ 'Content-Length': String(Buffer.byteLength(message))
387
+ };
388
+ for (var h in (extraHeaders || {})) headers[h] = extraHeaders[h];
389
+
390
+ var lines = ['HTTP/1.1 ' + code + ' ' + (STATUS_TEXT[code] || '')];
391
+ for (var name in headers) lines.push(name + ': ' + headers[name]);
392
+
393
+ try {
394
+ socket.once('finish', function () { try { socket.destroy(); } catch (e) {} });
395
+ socket.end(lines.join('\r\n') + '\r\n\r\n' + message);
396
+ } catch (e) {
397
+ try { socket.destroy(); } catch (e2) {}
398
+ }
399
+ }
400
+
401
+
402
+ /**
403
+ * Refuse an upgrade on whichever transport it arrived over.
404
+ *
405
+ * HTTP/1.1 gets a status line on the raw socket; HTTP/2 gets a response on the
406
+ * stream. Same intent, and the caller should not have to care which — the only
407
+ * reason this function exists is that the two cannot share bytes.
408
+ */
409
+ function rejectUpgrade(socket, isH2, code, message, extraHeaders) {
410
+ if (isH2 && typeof socket._h2Reject === 'function') {
411
+ socket._h2Reject(code, message);
412
+ return;
413
+ }
414
+ abortHandshake(socket, code, message, extraHeaders);
415
+ }
416
+
417
+
418
+ /** Parse Sec-WebSocket-Protocol into an ordered list, rejecting duplicates. */
419
+ function parseSubprotocols(header) {
420
+ var out = [];
421
+ var seen = Object.create(null);
422
+ var parts = String(header).split(',');
423
+ for (var i = 0; i < parts.length; i++) {
424
+ var p = parts[i].trim();
425
+ if (!isToken(p)) throw new Error('invalid subprotocol: ' + JSON.stringify(p));
426
+ if (seen[p]) throw new Error('duplicate subprotocol: ' + p);
427
+ seen[p] = true;
428
+ out.push(p);
429
+ }
430
+ return out;
431
+ }
432
+
433
+
434
+ /**
435
+ * Complete a WebSocket handshake on an upgraded socket and return the
436
+ * WebSocket.
437
+ *
438
+ * The primitive: `server.on('upgrade', (req, socket, head) => ...)` gives all
439
+ * three arguments this needs, so an application can accept a WebSocket
440
+ * anywhere it can see an upgrade, without a WebSocketServer at all.
441
+ *
442
+ * Returns null and answers the client itself if the handshake is invalid.
443
+ */
444
+ function upgradeWebSocket(req, socket, head, options) {
445
+ options = options || {};
446
+
447
+ var headers = req.headers || {};
448
+ var version = parseInt(headers['sec-websocket-version'], 10);
449
+
450
+ // ---- Which transport is this? ----
451
+ //
452
+ // Over HTTP/1.1 a WebSocket arrives as an Upgrade: a GET with
453
+ // Sec-WebSocket-Key, answered with 101 and a computed Accept.
454
+ //
455
+ // Over HTTP/2 it arrives as extended CONNECT (RFC 8441): a CONNECT with
456
+ // `:protocol: websocket`, answered with 200 — and with NO Sec-WebSocket-Key
457
+ // and no Accept at all. §5 removes them deliberately: the key exists only to
458
+ // stop a naive HTTP/1.1 server being tricked into completing a handshake by
459
+ // echoing a header, and HTTP/2 has no such confusion to prevent because the
460
+ // stream is already framed.
461
+ //
462
+ // Everything after this point — subprotocols, origin, and every frame — is
463
+ // identical, which is why the two transports share this function rather than
464
+ // having one each.
465
+ var isH2 = typeof socket._h2Accept === 'function';
466
+ var key = headers['sec-websocket-key'];
467
+ var upgrade = headers['upgrade'];
468
+
469
+ if (isH2) {
470
+ if (String(headers[':protocol'] || '').toLowerCase() !== 'websocket') {
471
+ rejectUpgrade(socket, isH2, 400, 'Extended CONNECT is not for websocket');
472
+ return null;
473
+ }
474
+ } else {
475
+ // Each of these is checked before any state is built, and each answers with
476
+ // the status RFC 6455 §4.2.1 calls for.
477
+ if (req.method !== 'GET') {
478
+ abortHandshake(socket, 405, 'Invalid HTTP method');
479
+ return null;
480
+ }
481
+ if (upgrade === undefined || String(upgrade).toLowerCase() !== 'websocket') {
482
+ abortHandshake(socket, 400, 'Invalid Upgrade header');
483
+ return null;
484
+ }
485
+ if (key === undefined || !KEY_RE.test(String(key))) {
486
+ // 16 random bytes in base64 is always 24 chars ending '=='. A client
487
+ // that sends anything else is not really speaking WebSocket.
488
+ abortHandshake(socket, 400, 'Missing or invalid Sec-WebSocket-Key header');
489
+ return null;
490
+ }
491
+ }
492
+
493
+ // Over HTTP/1.1 the header is mandatory: it is the only thing that
494
+ // distinguishes a WebSocket handshake from an ordinary Upgrade, and a
495
+ // request without it is not one.
496
+ //
497
+ // Over HTTP/2 and HTTP/3, `:protocol: websocket` has already said so
498
+ // unambiguously, and browsers do not reliably send Sec-WebSocket-Version on
499
+ // the extended CONNECT — Chrome's own network log of a WebSocket request
500
+ // shows five pseudo-headers and nothing else. RFC 8441 §5 asks for it, but
501
+ // refusing over it costs far more than it protects: the browser gets a 400,
502
+ // silently abandons HTTP/2, and reopens the WebSocket over HTTP/1.1. The
503
+ // connection still works, so nothing looks broken — it just never uses the
504
+ // multiplexed path, and the reason is invisible from both ends.
505
+ //
506
+ // So it is required where it carries meaning and merely validated where it
507
+ // does not: present and wrong is still an error, absent over h2/h3 is not.
508
+ if (isH2) {
509
+ if (headers['sec-websocket-version'] !== undefined && version !== 13) {
510
+ rejectUpgrade(socket, isH2, 400, 'Unsupported Sec-WebSocket-Version',
511
+ { 'Sec-WebSocket-Version': '13' });
512
+ return null;
513
+ }
514
+ } else if (version !== 13) {
515
+ // Only version 13 exists. The header tells the client which we accept
516
+ // (RFC 6455 §4.2.2).
517
+ abortHandshake(socket, 400, 'Unsupported Sec-WebSocket-Version',
518
+ { 'Sec-WebSocket-Version': '13' });
519
+ return null;
520
+ }
521
+
522
+ var offered = [];
523
+ if (headers['sec-websocket-protocol'] !== undefined) {
524
+ try {
525
+ offered = parseSubprotocols(headers['sec-websocket-protocol']);
526
+ } catch (err) {
527
+ rejectUpgrade(socket, isH2, 400, 'Invalid Sec-WebSocket-Protocol header');
528
+ return null;
529
+ }
530
+ }
531
+
532
+ // ---- Origin ----
533
+ //
534
+ // The same-origin policy does NOT apply to WebSocket: a browser will happily
535
+ // open one from any page to any host, and it sends the user's cookies with
536
+ // the handshake. Without an origin check, any site the user visits can talk
537
+ // to this server as that user — "cross-site WebSocket hijacking".
538
+ //
539
+ // So the check is opt-in but the default is a refusal to guess: with no
540
+ // `origin` option nothing is enforced (a server behind a gateway, or one
541
+ // that authenticates per message, is entitled to that), and the moment an
542
+ // allowlist is given it is enforced strictly.
543
+ if (options.origin !== undefined) {
544
+ var origin = headers['origin'];
545
+ var allowed = options.origin;
546
+ var ok;
547
+ if (typeof allowed === 'function') ok = !!allowed(origin, req);
548
+ else if (Array.isArray(allowed)) ok = allowed.indexOf(origin) !== -1;
549
+ else ok = (origin === allowed);
550
+ if (!ok) {
551
+ rejectUpgrade(socket, isH2, 403, 'Origin not allowed');
552
+ return null;
553
+ }
554
+ }
555
+
556
+ if (typeof options.verifyClient === 'function') {
557
+ var verdict = options.verifyClient(req);
558
+ if (verdict === false) { rejectUpgrade(socket, isH2, 401, 'Unauthorized'); return null; }
559
+ if (verdict && verdict.code) {
560
+ rejectUpgrade(socket, isH2, verdict.code, verdict.message, verdict.headers);
561
+ return null;
562
+ }
563
+ }
564
+
565
+ // A server may only select a protocol the client offered (§4.2.2).
566
+ var chosen = null;
567
+ if (options.protocol !== undefined && options.protocol !== null) {
568
+ var want = typeof options.protocol === 'function'
569
+ ? options.protocol(offered, req)
570
+ : options.protocol;
571
+ if (want) {
572
+ if (offered.indexOf(want) === -1) {
573
+ rejectUpgrade(socket, isH2, 400, 'Requested subprotocol was not offered by the client');
574
+ return null;
575
+ }
576
+ chosen = want;
577
+ }
578
+ } else if (options.protocols && offered.length) {
579
+ for (var i = 0; i < options.protocols.length && chosen === null; i++) {
580
+ if (offered.indexOf(options.protocols[i]) !== -1) chosen = options.protocols[i];
581
+ }
582
+ }
583
+
584
+ try {
585
+ if (isH2) {
586
+ // §5: a 2xx accepts the channel. No 101, no Accept — see the note above.
587
+ var h2Headers = {};
588
+ if (chosen) h2Headers['sec-websocket-protocol'] = chosen;
589
+ for (var hn2 in (options.headers || {})) h2Headers[hn2] = options.headers[hn2];
590
+ socket._h2Accept(h2Headers);
591
+ } else {
592
+ var responseLines = [
593
+ 'HTTP/1.1 101 Switching Protocols',
594
+ 'Upgrade: websocket',
595
+ 'Connection: Upgrade',
596
+ // The one value that proves we are a WebSocket server and not an HTTP
597
+ // server tricked into echoing a header (§1.3).
598
+ 'Sec-WebSocket-Accept: ' + acceptKey(String(key))
599
+ ];
600
+ if (chosen) responseLines.push('Sec-WebSocket-Protocol: ' + chosen);
601
+ for (var hn in (options.headers || {})) responseLines.push(hn + ': ' + options.headers[hn]);
602
+ socket.write(responseLines.join('\r\n') + '\r\n\r\n');
603
+ }
604
+ } catch (err) {
605
+ try { socket.destroy(); } catch (e) {}
606
+ return null;
607
+ }
608
+
609
+ var scheme = (socket.encrypted || req.socket && req.socket.encrypted) ? 'wss' : 'ws';
610
+ return new ServerWebSocket(socket, {
611
+ protocol: chosen || '',
612
+ head: head,
613
+ maxPayload: options.maxPayload,
614
+ // The origin the client asked for: Host over HTTP/1.1, :authority over
615
+ // HTTP/2 and HTTP/3 (RFC 9113 §8.3.1, RFC 9114 §4.3.1) — over HTTP/3 the
616
+ // request carries no Host at all, and the URL came out as "wss:///path".
617
+ url: scheme + '://' + (headers['host'] || headers[':authority'] || '') + (req.url || '')
618
+ });
619
+ }
620
+
621
+
622
+ // ============================================================
623
+ // WebSocketServer
624
+ // ============================================================
625
+
626
+ /**
627
+ * Attach to a quico (or node:http) server and hand out WebSockets.
628
+ *
629
+ * const wss = new WebSocketServer({ server, path: '/chat' });
630
+ * wss.onconnection = (ws, req) => {
631
+ * ws.onmessage = e => ws.send(e.data);
632
+ * };
633
+ *
634
+ * `onconnection` rather than on('connection'): if the connection object is the
635
+ * browser's WebSocket, the surface around it should read the same way.
636
+ * addEventListener works too, for the same reason.
637
+ */
638
+ class WebSocketServer extends EventTarget {
639
+
640
+ constructor(options) {
641
+ super();
642
+ options = options || {};
643
+ this.options = options;
644
+ this.clients = new Set();
645
+ this._onconnection = null;
646
+ this._pingTimer = null;
647
+
648
+ // The server object gets both surfaces as well: wss.onconnection is the
649
+ // browser-style spelling, wss.on('connection') the ws one, and both
650
+ // receive the same two arguments — there is no payload disagreement here
651
+ // to preserve.
652
+ installEventSurfaces(this);
653
+
654
+ // `noServer: true` is the ws spelling of "I will call handleUpgrade
655
+ // myself". Nothing to do for it here — not passing a `server` already has
656
+ // that effect — but accepting the option means ws code reads the same.
657
+ if (options.server && !options.noServer) {
658
+ var self = this;
659
+ options.server.on('upgrade', function (req, socket, head) {
660
+ self.handleUpgrade(req, socket, head);
661
+ });
662
+ }
663
+
664
+ // Keepalive. Off unless asked for, because it costs a frame per socket per
665
+ // interval — but a long-lived server almost always wants it: see ping().
666
+ if (options.pingInterval > 0) this.startAutoPing(options.pingInterval);
667
+ }
668
+
669
+ get onconnection() { return this._onconnection; }
670
+ set onconnection(fn) {
671
+ if (this._onconnection) this.removeEventListener('connection', this._onconnection);
672
+ this._onconnection = typeof fn === 'function' ? fn : null;
673
+ if (this._onconnection) this.addEventListener('connection', this._onconnection);
674
+ }
675
+
676
+ /**
677
+ * Accept (or refuse) one upgrade. Public so an application can route
678
+ * upgrades itself and hand only some of them here.
679
+ */
680
+ /**
681
+ * Accept (or refuse) one upgrade.
682
+ *
683
+ * `cb` is the ws-compatible form: `handleUpgrade(req, socket, head, ws => …)`
684
+ * hands the socket to the callback and does NOT fire the connection event,
685
+ * because that is what `ws` does — with noServer the callback is the whole
686
+ * delivery, and code written against it emits 'connection' itself if it
687
+ * wants to. Omit the callback and the connection event fires as usual.
688
+ */
689
+ handleUpgrade(req, socket, head, cb) {
690
+ if (this.options.path !== undefined) {
691
+ var path = String(req.url || '').split('?')[0];
692
+ if (path !== this.options.path) {
693
+ // Not ours. Another WebSocketServer on the same server may want it, so
694
+ // do not refuse on its behalf — but do not simply walk away either:
695
+ // the socket has been handed over by the HTTP layer and nothing else
696
+ // owns it, so an unclaimed upgrade hangs until the client times out.
697
+ //
698
+ // Refusing after a turn of the event loop gives any other handler
699
+ // registered on the same 'upgrade' event a chance to claim it first;
700
+ // if none did, the client gets a 404 and knows immediately.
701
+ var isH2Stream = typeof socket._h2Reject === 'function';
702
+ queueMicrotask(function () {
703
+ if (socket.destroyed || socket._quicoWsClaimed) return;
704
+ // Transport-aware, for the same reason every other refusal is: over
705
+ // HTTP/2 there is no socket to write a status line onto, and writing
706
+ // one would put HTTP/1.1 bytes into a DATA frame. The h2 path
707
+ // answers on the stream instead.
708
+ rejectUpgrade(socket, isH2Stream, 404, 'No WebSocket endpoint at ' + path);
709
+ });
710
+ return false;
711
+ }
712
+ socket._quicoWsClaimed = true;
713
+ }
714
+
715
+ var ws = upgradeWebSocket(req, socket, head, this.options);
716
+ if (!ws) return false;
717
+
718
+ var self = this;
719
+ this.clients.add(ws);
720
+ ws.addEventListener('close', function () { self.clients.delete(ws); });
721
+
722
+ if (typeof cb === 'function') { cb(ws, req); return true; }
723
+
724
+ // dispatchEvent carries one argument and a connection handler needs two —
725
+ // the socket and the request that produced it — so the handler is called
726
+ // directly, and the Event is dispatched as well (carrying both on itself)
727
+ // so addEventListener still sees the connection.
728
+ if (this._onconnection) this._onconnection(ws, req);
729
+
730
+ var ev = new Event('connection');
731
+ try {
732
+ Object.defineProperty(ev, 'webSocket', { value: ws, enumerable: true });
733
+ Object.defineProperty(ev, 'request', { value: req, enumerable: true });
734
+ } catch (e) {}
735
+ var handler = this._onconnection;
736
+ if (handler) this.removeEventListener('connection', handler);
737
+ this.dispatchEvent(ev);
738
+ if (handler) this.addEventListener('connection', handler);
739
+
740
+ // ws-style listeners get (ws, req), the signature every ws example uses.
741
+ this._emitNode('connection', [ws, req]);
742
+
743
+ return true;
744
+ }
745
+
746
+ /**
747
+ * Ping every client on an interval and drop the ones that stop answering.
748
+ *
749
+ * The reason a server needs this and a browser does not: a peer that
750
+ * disappears without a FIN — a closed laptop, a NAT that forgot the mapping
751
+ * — leaves a socket that reads as healthy indefinitely. Nothing else
752
+ * notices.
753
+ */
754
+ startAutoPing(intervalMs) {
755
+ var self = this;
756
+ this.stopAutoPing();
757
+ this._pingTimer = setInterval(function () {
758
+ for (var ws of self.clients) {
759
+ if (ws.readyState !== OPEN) continue;
760
+ if (ws._awaitingPong && !ws._pongSeen) {
761
+ // Missed the previous round: the peer is gone.
762
+ ws._fail(new Error('no pong received'));
763
+ continue;
764
+ }
765
+ ws._awaitingPong = true;
766
+ ws.ping();
767
+ }
768
+ }, intervalMs);
769
+ if (this._pingTimer.unref) this._pingTimer.unref();
770
+ }
771
+
772
+ stopAutoPing() {
773
+ if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
774
+ }
775
+
776
+ /**
777
+ * Close every client, then stop.
778
+ *
779
+ * 1001 "going away" is exactly the right code for a server shutting down —
780
+ * and the WHATWG close() refuses it, because the browser reserves 1001 for
781
+ * the browser itself (a tab being closed) and only lets a page pass 1000 or
782
+ * 3000-4999. The server is not a page, and RFC 6455 §7.4.1 defines 1001 for
783
+ * precisely this situation, so it goes out through the internal path rather
784
+ * than through the browser-facing method.
785
+ *
786
+ * The first version called ws.close(1001) inside a try/catch and swallowed
787
+ * the DOMException it raised on every client: close() did nothing at all,
788
+ * silently, and the sockets stayed open.
789
+ */
790
+ close(code, reason) {
791
+ this.stopAutoPing();
792
+ var reasonBuf = reason ? Buffer.from(String(reason), 'utf8') : Buffer.alloc(0);
793
+ if (reasonBuf.length > 123) reasonBuf = reasonBuf.subarray(0, 123);
794
+ var finalCode = code === undefined ? 1001 : code;
795
+
796
+ for (var ws of this.clients) {
797
+ if (ws.readyState !== OPEN) continue;
798
+ ws._readyState = CLOSING;
799
+ ws._closeCode = finalCode;
800
+ ws._closeReason = reasonBuf.toString('utf8');
801
+ ws._sendCloseFrame(finalCode, reasonBuf);
802
+
803
+ // The peer should echo the close frame; if it does not, do not hold the
804
+ // socket open forever on a server that is shutting down.
805
+ (function (sock) {
806
+ var t = setTimeout(function () { sock._teardown(false); }, 5000);
807
+ if (t.unref) t.unref();
808
+ sock._closeTimer = t;
809
+ })(ws);
810
+ }
811
+ }
812
+ }
813
+
814
+
815
+ export { WebSocketServer, ServerWebSocket, upgradeWebSocket, abortHandshake };