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,250 @@
1
+ /*
2
+ * quico: HTTP/3 and QUIC implementation for Node.js
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://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * This file is part of the open-source project hosted at:
12
+ * https://github.com/colocohen/quico
13
+ */
14
+
15
+ // ============================================================
16
+ // WebSocketStream — the browser's streams-based WebSocket
17
+ //
18
+ // https://github.com/ricea/websocketstream-explainer
19
+ // (WPT websockets/stream/tentative: not yet a standard; Chrome has it)
20
+ //
21
+ // The same connection as WebSocket (ws_client.js underneath), with streams
22
+ // in place of events, so that both directions have backpressure:
23
+ //
24
+ // const wss = new WebSocketStream(url, { protocols, signal });
25
+ // const { readable, writable, protocol, extensions } = await wss.opened;
26
+ // for await (const message of readable) … // a string or a Uint8Array
27
+ // await writable.getWriter().write('hello'); // resolves once it is sent
28
+ // wss.close({ closeCode, reason });
29
+ // const { closeCode, reason } = await wss.closed;
30
+ //
31
+ // Receiving: a message nobody has read holds the socket — it is paused, TCP
32
+ // fills up, and the server feels it. WebSocket's 'message' events take every
33
+ // message as it comes, however fast, into memory.
34
+ // Sending: write() resolves when the frame has gone to the socket, not when
35
+ // it was queued — so a writer that awaits it goes as fast as the connection
36
+ // does. WebSocket's send() returns at once; bufferedAmount is all there is.
37
+ //
38
+ // Besides the browser's protocols and signal, the options bag takes QUICO's
39
+ // WebSocket options (origin, headers, ca, rejectUnauthorized, agent, …).
40
+ // ============================================================
41
+
42
+ import { WebSocket } from './ws_client.js';
43
+ import { userCloseCode, closeReasonBytes, subprotocolList, sendInOrder, OPCODE } from './ws.js';
44
+
45
+ // The browser's WebSocketError: a DOMException named 'WebSocketError', with
46
+ // the close code and reason. new WebSocketError(message, { closeCode, reason })
47
+ // takes the codes close() takes (1000, 3000–4999) and a reason of at most 123
48
+ // bytes; a reason without a code is 1000.
49
+ class WebSocketError extends DOMException {
50
+ constructor(message, init) {
51
+ super(message === undefined ? '' : String(message), 'WebSocketError');
52
+ if (init !== undefined && init !== null && typeof init !== 'object') throw new TypeError('WebSocketError options must be an object');
53
+ init = init || {};
54
+ var code = init.closeCode !== undefined && init.closeCode !== null ? userCloseCode(init.closeCode) : null;
55
+ var reason = init.reason !== undefined ? String(init.reason) : '';
56
+ if (reason) closeReasonBytes(reason);
57
+ if (code === null && reason) code = 1000;
58
+ Object.defineProperty(this, '_wse', { value: { closeCode: code, reason: reason } });
59
+ }
60
+ get closeCode() { return this._wse.closeCode; }
61
+ get reason() { return this._wse.reason; }
62
+ }
63
+
64
+ // One of ours, with a code a constructor would refuse (1006: the connection
65
+ // went without a closing handshake).
66
+ function connectionError(message, closeCode, reason) {
67
+ var e = new WebSocketError(message);
68
+ e._wse.closeCode = closeCode;
69
+ e._wse.reason = reason || '';
70
+ return e;
71
+ }
72
+
73
+ function invalidState(message) { return new DOMException(message, 'InvalidStateError'); }
74
+
75
+ // A chunk written: an ArrayBuffer or a view onto one goes as a binary message
76
+ // (not one on shared or resizable memory: a TypeError, as the browser has
77
+ // it), anything else as text, stringified (a value that cannot be: TypeError).
78
+ function messageOf(chunk) {
79
+ if (chunk instanceof ArrayBuffer || ArrayBuffer.isView(chunk) ||
80
+ (typeof SharedArrayBuffer !== 'undefined' && chunk instanceof SharedArrayBuffer)) {
81
+ var buf = ArrayBuffer.isView(chunk) ? chunk.buffer : chunk;
82
+ if ((typeof SharedArrayBuffer !== 'undefined' && buf instanceof SharedArrayBuffer) || buf.resizable || buf.growable) {
83
+ throw new TypeError('a WebSocketStream cannot send shared or resizable memory');
84
+ }
85
+ var u8 = ArrayBuffer.isView(chunk) ? new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) : new Uint8Array(chunk);
86
+ return { opcode: OPCODE.BINARY, payload: Buffer.from(u8) }; // a copy: the caller may reuse its buffer
87
+ }
88
+ return { opcode: OPCODE.TEXT, payload: Buffer.from(String(chunk), 'utf8') };
89
+ }
90
+
91
+ class WebSocketStream {
92
+ constructor(url, options) {
93
+ if (arguments.length === 0) throw new TypeError('Failed to construct \'WebSocketStream\': 1 argument required, but only 0 present.');
94
+ if (options !== undefined && options !== null && typeof options !== 'object') throw new TypeError('WebSocketStream options must be an object');
95
+ options = options || {};
96
+ var protocols = subprotocolList(options.protocols, true);
97
+ var signal = options.signal;
98
+ if (signal !== undefined && signal !== null && !(signal instanceof AbortSignal)) throw new TypeError('options.signal must be an AbortSignal');
99
+
100
+ var nodeOptions = {};
101
+ for (var k in options) if (k !== 'protocols' && k !== 'signal') nodeOptions[k] = options[k];
102
+
103
+ // The URL is checked as WebSocket checks it (a SyntaxError); its
104
+ // connection starts on the next turn — not at all if aborted by then.
105
+ var ws = this._ws = new WebSocket(url, protocols, nodeOptions);
106
+ ws.binaryType = 'nodebuffer';
107
+ this._url = ws.url;
108
+
109
+ var self = this;
110
+ var openedResolve, openedReject, closedResolve, closedReject;
111
+ this.opened = new Promise(function (res, rej) { openedResolve = res; openedReject = rej; });
112
+ this.closed = new Promise(function (res, rej) { closedResolve = res; closedReject = rej; });
113
+ this.opened.catch(function () {});
114
+ this.closed.catch(function () {});
115
+
116
+ var state = 'connecting'; // connecting → open → closed
117
+ var pendingWrites = 0;
118
+ var readCtl = null, writeCtl = null, writableClosing = null;
119
+ this._state = function () { return state; };
120
+
121
+ function settleFailed(err) {
122
+ if (state === 'closed') return;
123
+ state = 'closed';
124
+ openedReject(err);
125
+ closedReject(err);
126
+ }
127
+
128
+ // The signal: before the connection is made, it is never attempted;
129
+ // during the handshake, it is abandoned; once open, it does nothing.
130
+ function onAbort() {
131
+ if (state !== 'connecting') return;
132
+ var reason = signal.reason !== undefined ? signal.reason : new DOMException('The operation was aborted.', 'AbortError');
133
+ settleFailed(reason);
134
+ try { ws.close(); } catch (e) {}
135
+ }
136
+ if (signal) {
137
+ if (signal.aborted) { onAbort(); return; }
138
+ signal.addEventListener('abort', onAbort, { once: true });
139
+ }
140
+
141
+ ws.addEventListener('open', function () {
142
+ if (signal) signal.removeEventListener('abort', onAbort);
143
+ if (state !== 'connecting') return;
144
+ state = 'open';
145
+ var socket = ws._socket;
146
+
147
+ var readable = new ReadableStream({
148
+ start: function (c) { readCtl = c; },
149
+ // Read: the socket goes on. Unread: it is paused (see 'message').
150
+ pull: function () { if (socket && !socket.destroyed && socket.isPaused()) socket.resume(); },
151
+ cancel: function (reason) { closeWith(reason); }
152
+ });
153
+
154
+ var writable = new WritableStream({
155
+ start: function (c) { writeCtl = c; },
156
+ write: function (chunk) {
157
+ var m = messageOf(chunk);
158
+ if (ws.readyState !== WebSocket.OPEN) throw invalidState('the WebSocket is closed');
159
+ return new Promise(function (resolve, reject) {
160
+ pendingWrites++;
161
+ sendInOrder(ws, m.opcode, m.payload, function (err) {
162
+ pendingWrites--;
163
+ if (err) reject(invalidState('the WebSocket closed before the message was sent'));
164
+ else resolve();
165
+ });
166
+ });
167
+ },
168
+ // A clean close, no code; done once the closing handshake is.
169
+ close: function () {
170
+ return new Promise(function (resolve) {
171
+ writableClosing = resolve;
172
+ try { ws.close(); } catch (e) { resolve(); }
173
+ });
174
+ },
175
+ abort: function (reason) { closeWith(reason); }
176
+ });
177
+
178
+ openedResolve({ readable: readable, writable: writable, protocol: ws.protocol, extensions: ws.extensions });
179
+ });
180
+
181
+ ws.addEventListener('message', function (e) {
182
+ if (!readCtl) return;
183
+ var data = typeof e.data === 'string' ? e.data : new Uint8Array(e.data);
184
+ try { readCtl.enqueue(data); } catch (x) { return; }
185
+ // Nobody reading: hold the socket until somebody does. The server's
186
+ // sends then wait on TCP — the receiving side's backpressure.
187
+ var socket = ws._socket;
188
+ if (readCtl.desiredSize <= 0 && socket && !socket.isPaused()) socket.pause();
189
+ });
190
+
191
+ ws.addEventListener('close', function (e) {
192
+ if (signal) signal.removeEventListener('abort', onAbort);
193
+ if (writableClosing) { var done = writableClosing; writableClosing = null; done(); }
194
+ if (state === 'connecting') {
195
+ settleFailed(connectionError('the WebSocket connection could not be established', 1006, ''));
196
+ return;
197
+ }
198
+ if (state === 'closed') return;
199
+ state = 'closed';
200
+ if (e.wasClean && pendingWrites === 0) {
201
+ // Clean: the peer's code and reason. Nothing more can be written.
202
+ closedResolve({ closeCode: e.code, reason: e.reason });
203
+ try { readCtl.close(); } catch (x) {}
204
+ try { writeCtl.error(invalidState('the WebSocket is closed')); } catch (x) {}
205
+ } else {
206
+ // Not clean — no closing handshake, or one that left messages
207
+ // unsent: the same error for closed, the readable and the writable.
208
+ var err = connectionError(e.wasClean ? 'the WebSocket closed with messages unsent' : 'the WebSocket connection was lost',
209
+ e.wasClean ? e.code : 1006, e.wasClean ? e.reason : '');
210
+ closedReject(err);
211
+ try { readCtl.error(err); } catch (x) {}
212
+ try { writeCtl.error(err); } catch (x) {}
213
+ }
214
+ });
215
+
216
+ // abort() of the writable, cancel() of the readable: closed with the
217
+ // code and reason of a WebSocketError given as the reason; anything else
218
+ // is a close with no code.
219
+ function closeWith(reason) {
220
+ if (reason instanceof WebSocketError && reason.closeCode !== null) {
221
+ try { ws.close(reason.closeCode, reason.reason); } catch (e) { try { ws.close(); } catch (x) {} }
222
+ } else {
223
+ try { ws.close(); } catch (e) {}
224
+ }
225
+ }
226
+
227
+ }
228
+
229
+ get url() { return this._url; }
230
+
231
+ /**
232
+ * close({ closeCode, reason }): as WebSocket's close() checks them; a
233
+ * reason without a code is 1000, neither is a close with no code (1005 at
234
+ * the peer). During the handshake: abandoned — opened and closed reject.
235
+ */
236
+ close(closeInfo) {
237
+ if (closeInfo !== undefined && closeInfo !== null && typeof closeInfo !== 'object') {
238
+ throw new TypeError('close() takes { closeCode, reason }');
239
+ }
240
+ var code = closeInfo && closeInfo.closeCode !== undefined ? userCloseCode(closeInfo.closeCode) : undefined;
241
+ var reason = closeInfo && closeInfo.reason !== undefined ? String(closeInfo.reason) : '';
242
+ closeReasonBytes(reason);
243
+ if (code === undefined && reason) code = 1000;
244
+ if (this._state() === 'closed') return;
245
+ if (this._state() === 'connecting') { try { this._ws.close(); } catch (e) {} return; }
246
+ this._ws.close(code, code === undefined ? undefined : reason);
247
+ }
248
+ }
249
+
250
+ export { WebSocketStream, WebSocketError };