transport-io 0.0.1
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/LICENSE +21 -0
- package/README.md +28 -0
- package/dist/adapter.d.ts +69 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +51 -0
- package/dist/adapter.js.map +1 -0
- package/dist/client.d.ts +60 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +162 -0
- package/dist/client.js.map +1 -0
- package/dist/codec.d.ts +5 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +47 -0
- package/dist/codec.js.map +1 -0
- package/dist/contract.d.ts +79 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +54 -0
- package/dist/contract.js.map +1 -0
- package/dist/datagram.d.ts +29 -0
- package/dist/datagram.d.ts.map +1 -0
- package/dist/datagram.js +92 -0
- package/dist/datagram.js.map +1 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +11 -0
- package/dist/errors.js.map +1 -0
- package/dist/framer.d.ts +21 -0
- package/dist/framer.d.ts.map +1 -0
- package/dist/framer.js +110 -0
- package/dist/framer.js.map +1 -0
- package/dist/handshake.d.ts +34 -0
- package/dist/handshake.d.ts.map +1 -0
- package/dist/handshake.js +79 -0
- package/dist/handshake.js.map +1 -0
- package/dist/hub.d.ts +43 -0
- package/dist/hub.d.ts.map +1 -0
- package/dist/hub.js +149 -0
- package/dist/hub.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/origin.d.ts +18 -0
- package/dist/origin.d.ts.map +1 -0
- package/dist/origin.js +72 -0
- package/dist/origin.js.map +1 -0
- package/dist/protocol.d.ts +79 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +76 -0
- package/dist/protocol.js.map +1 -0
- package/dist/queue.d.ts +45 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/queue.js +94 -0
- package/dist/queue.js.map +1 -0
- package/dist/server.d.ts +45 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +127 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +94 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +645 -0
- package/dist/session.js.map +1 -0
- package/dist/testing/hostile-adapter.d.ts +44 -0
- package/dist/testing/hostile-adapter.d.ts.map +1 -0
- package/dist/testing/hostile-adapter.js +116 -0
- package/dist/testing/hostile-adapter.js.map +1 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +8 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/transport/browser.d.ts +8 -0
- package/dist/transport/browser.d.ts.map +1 -0
- package/dist/transport/browser.js +89 -0
- package/dist/transport/browser.js.map +1 -0
- package/dist/transport/fails.node.d.ts +33 -0
- package/dist/transport/fails.node.d.ts.map +1 -0
- package/dist/transport/fails.node.js +184 -0
- package/dist/transport/fails.node.js.map +1 -0
- package/dist/transport/loopback.d.ts +11 -0
- package/dist/transport/loopback.d.ts.map +1 -0
- package/dist/transport/loopback.js +98 -0
- package/dist/transport/loopback.js.map +1 -0
- package/dist/transport/moq.node.d.ts +19 -0
- package/dist/transport/moq.node.d.ts.map +1 -0
- package/dist/transport/moq.node.js +211 -0
- package/dist/transport/moq.node.js.map +1 -0
- package/dist/transport/parity-suite.d.ts +40 -0
- package/dist/transport/parity-suite.d.ts.map +1 -0
- package/dist/transport/parity-suite.js +133 -0
- package/dist/transport/parity-suite.js.map +1 -0
- package/dist/transport/types.d.ts +36 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/types.js +2 -0
- package/dist/transport/types.js.map +1 -0
- package/dist/transport/unreliable.d.ts +37 -0
- package/dist/transport/unreliable.d.ts.map +1 -0
- package/dist/transport/unreliable.js +59 -0
- package/dist/transport/unreliable.js.map +1 -0
- package/package.json +55 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One side of a session. Both the server's view of a peer and the client's view of the
|
|
3
|
+
* server are this class — the handshake is symmetric, and so is everything after it.
|
|
4
|
+
*/
|
|
5
|
+
import { decodePayload, encodePayload, validate } from './codec.js';
|
|
6
|
+
import { decodeDatagram, encodeDatagram, SequenceGate } from './datagram.js';
|
|
7
|
+
import { TransportError } from './errors.js';
|
|
8
|
+
import { encodeFrame, FrameDecoder } from './framer.js';
|
|
9
|
+
import { buildHandshake, negotiate, parseHandshake } from './handshake.js';
|
|
10
|
+
import { CLOSE_REASON_MAX_BYTES, CloseCode, Codec, EVENT_ID_NOT_APPLICABLE, FrameType, HANDSHAKE_DEADLINE_MS, MAX_CONCURRENT_CALL_STREAMS, ResetCode, SEQUENCE_STATE_RETENTION_MS, } from './protocol.js';
|
|
11
|
+
import { DatagramQueue, EmitQueue, PeerTooSlowError } from './queue.js';
|
|
12
|
+
/**
|
|
13
|
+
* §10.2 exists so that a peer can tell a version disagreement from a framing bug. Closing
|
|
14
|
+
* every refusal with 1004 made the table decorative: an implementer told "unrecoverable
|
|
15
|
+
* framing violation" goes looking for a framing bug that is not there, and a peer that
|
|
16
|
+
* retries on 1004 retries forever against a mismatch that will never resolve.
|
|
17
|
+
*/
|
|
18
|
+
function closeCodeFor(e) {
|
|
19
|
+
if (e instanceof TransportError) {
|
|
20
|
+
if (e.code === 'WT_PROTOCOL_VERSION_MISMATCH')
|
|
21
|
+
return CloseCode.WT_PROTOCOL_VERSION_MISMATCH;
|
|
22
|
+
if (e.code === 'WT_CONTRACT_MISMATCH')
|
|
23
|
+
return CloseCode.WT_CONTRACT_MISMATCH;
|
|
24
|
+
}
|
|
25
|
+
return CloseCode.WT_PROTOCOL_ERROR;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* §10.2 caps the reason at 1024 **bytes**. Slicing to 1024 characters overshoots by up to
|
|
29
|
+
* threefold on non-ASCII — and event names, which appear in mismatch messages, are the
|
|
30
|
+
* user's own domain language. Truncated on a code-point boundary so the result is never
|
|
31
|
+
* a broken surrogate pair.
|
|
32
|
+
*/
|
|
33
|
+
function closeReason(e) {
|
|
34
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
if (encoder.encode(message).byteLength <= CLOSE_REASON_MAX_BYTES)
|
|
37
|
+
return message;
|
|
38
|
+
let out = '';
|
|
39
|
+
let bytes = 0;
|
|
40
|
+
for (const ch of message) {
|
|
41
|
+
const size = encoder.encode(ch).byteLength;
|
|
42
|
+
if (bytes + size > CLOSE_REASON_MAX_BYTES)
|
|
43
|
+
break;
|
|
44
|
+
out += ch;
|
|
45
|
+
bytes += size;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
function isAbort(e) {
|
|
50
|
+
const name = e?.name;
|
|
51
|
+
return name === 'AbortError' || name === 'TimeoutError';
|
|
52
|
+
}
|
|
53
|
+
function abortToTransportError(cause) {
|
|
54
|
+
const timedOut = cause?.name === 'TimeoutError';
|
|
55
|
+
return new TransportError('WT_ABORTED', timedOut ? 'the call timed out before the responder answered' : 'the call was aborted', 'The stream was reset, so the responder was told. Retry if the work is idempotent, or raise the deadline.');
|
|
56
|
+
}
|
|
57
|
+
export class Session {
|
|
58
|
+
#conn;
|
|
59
|
+
#table;
|
|
60
|
+
#origin;
|
|
61
|
+
#validateInbound;
|
|
62
|
+
#now;
|
|
63
|
+
#deadlineMs;
|
|
64
|
+
#schedule;
|
|
65
|
+
#flushScheduled = false;
|
|
66
|
+
#handlers = new Map();
|
|
67
|
+
#gate = new SequenceGate();
|
|
68
|
+
#dgQueue = new DatagramQueue();
|
|
69
|
+
#emitQueue = new EmitQueue();
|
|
70
|
+
#sequences = new Map();
|
|
71
|
+
#controlHandlers = new Set();
|
|
72
|
+
#callHandlers = new Map();
|
|
73
|
+
#openCalls = 0;
|
|
74
|
+
#inboundCalls = 0;
|
|
75
|
+
#writer;
|
|
76
|
+
#writing = false;
|
|
77
|
+
#handshakeSent = false;
|
|
78
|
+
#negotiated;
|
|
79
|
+
#handshakeResolve;
|
|
80
|
+
#handshakeReject;
|
|
81
|
+
#sweepTimer;
|
|
82
|
+
#disposed = false;
|
|
83
|
+
/** Resolves when both sides have exchanged a valid handshake. */
|
|
84
|
+
ready;
|
|
85
|
+
constructor(conn, opts) {
|
|
86
|
+
this.#conn = conn;
|
|
87
|
+
this.#table = opts.table;
|
|
88
|
+
this.#origin = opts.origin;
|
|
89
|
+
this.#validateInbound = opts.validateInbound ?? true;
|
|
90
|
+
this.#now = opts.now ?? (() => Date.now());
|
|
91
|
+
this.#deadlineMs = opts.handshakeDeadlineMs ?? HANDSHAKE_DEADLINE_MS;
|
|
92
|
+
this.#schedule = opts.scheduleFlush ?? ((flush) => queueMicrotask(flush));
|
|
93
|
+
this.ready = new Promise((res, rej) => {
|
|
94
|
+
this.#handshakeResolve = res;
|
|
95
|
+
this.#handshakeReject = rej;
|
|
96
|
+
});
|
|
97
|
+
// `ready` is rejected from the emit-stream read loop, which can reach a refusal before
|
|
98
|
+
// start() has got as far as awaiting it — the peer's handshake is frame 0 and may be
|
|
99
|
+
// decoded during our own `openEmitStream()`. An unobserved rejection terminates a Node
|
|
100
|
+
// server by default, so it is observed here. start() still surfaces it to its caller.
|
|
101
|
+
void this.ready.catch(() => undefined);
|
|
102
|
+
}
|
|
103
|
+
get origin() {
|
|
104
|
+
return this.#origin;
|
|
105
|
+
}
|
|
106
|
+
async start() {
|
|
107
|
+
// Whoever closes, both sides release. Registered before anything can fail, so a
|
|
108
|
+
// session that dies during the handshake is cleaned up too.
|
|
109
|
+
void this.#conn.closed.then(() => this.dispose());
|
|
110
|
+
this.#conn.onEmitStream((readable) => void this.#readEmitStream(readable));
|
|
111
|
+
this.#conn.onBidi((stream) => this.#acceptCall(stream));
|
|
112
|
+
this.#conn.onDatagram((bytes) => this.#onDatagram(bytes));
|
|
113
|
+
/**
|
|
114
|
+
* Armed before the stream is opened, and raced against every await that follows.
|
|
115
|
+
*
|
|
116
|
+
* It used to be armed *after* `openEmitStream()` and after our own handshake write, so
|
|
117
|
+
* if either never settled — precisely the stalled-peer case this deadline exists for —
|
|
118
|
+
* no timer was ever armed and `connect()` hung for ever. Racing `ready` instead would
|
|
119
|
+
* not work: a peer whose handshake arrives before we have opened our own stream
|
|
120
|
+
* resolves `ready` early, and the race would fire on success.
|
|
121
|
+
*/
|
|
122
|
+
let onDeadline;
|
|
123
|
+
const deadline = new Promise((_, reject) => {
|
|
124
|
+
onDeadline = reject;
|
|
125
|
+
});
|
|
126
|
+
void deadline.catch(() => undefined); // observed, so it can never be an unhandled rejection
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
const e = new TransportError('WT_HANDSHAKE_TIMEOUT', `no handshake within ${this.#deadlineMs}ms`, 'The session opened but no application bytes arrived. Some browsers establish a WebTransport session and then never transmit; that combination is unsupported.');
|
|
129
|
+
this.#handshakeReject(e);
|
|
130
|
+
this.close(CloseCode.WT_HANDSHAKE_TIMEOUT, 'handshake deadline');
|
|
131
|
+
onDeadline(e);
|
|
132
|
+
}, this.#deadlineMs);
|
|
133
|
+
const writable = await Promise.race([this.#conn.openEmitStream(), deadline]);
|
|
134
|
+
const writer = writable.getWriter();
|
|
135
|
+
this.#writer = writer;
|
|
136
|
+
// Frame 0 of the emit stream. In-order delivery within a stream makes early traffic
|
|
137
|
+
// impossible by construction, so there is no race to guard.
|
|
138
|
+
await Promise.race([
|
|
139
|
+
writer.write(encodeFrame({
|
|
140
|
+
type: FrameType.HANDSHAKE,
|
|
141
|
+
codec: Codec.JSON,
|
|
142
|
+
eventId: EVENT_ID_NOT_APPLICABLE,
|
|
143
|
+
payload: encodePayload(buildHandshake(this.#table)),
|
|
144
|
+
})),
|
|
145
|
+
deadline,
|
|
146
|
+
]);
|
|
147
|
+
this.#handshakeSent = true;
|
|
148
|
+
this.#flushEmits();
|
|
149
|
+
try {
|
|
150
|
+
const n = await this.ready;
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
this.#sweepTimer = setInterval(() => {
|
|
153
|
+
this.#gate.sweep(this.#now(), SEQUENCE_STATE_RETENTION_MS);
|
|
154
|
+
}, SEQUENCE_STATE_RETENTION_MS);
|
|
155
|
+
this.#sweepTimer.unref?.();
|
|
156
|
+
return n;
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
throw e;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** JOIN and LEAVE are server-to-client notifications, so a client can keep an accurate
|
|
164
|
+
* view of its own membership. Rooms are server-authoritative; this is not a request. */
|
|
165
|
+
onControl(cb) {
|
|
166
|
+
this.#controlHandlers.add(cb);
|
|
167
|
+
return () => {
|
|
168
|
+
this.#controlHandlers.delete(cb);
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
on(event, handler) {
|
|
172
|
+
let set = this.#handlers.get(event);
|
|
173
|
+
if (set === undefined) {
|
|
174
|
+
set = new Set();
|
|
175
|
+
this.#handlers.set(event, set);
|
|
176
|
+
}
|
|
177
|
+
set.add(handler);
|
|
178
|
+
return () => {
|
|
179
|
+
set.delete(handler);
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** Fire and forget on whichever lane the contract declared. The call site never chooses. */
|
|
183
|
+
emit(event, payload) {
|
|
184
|
+
const entry = this.#table.byName(event);
|
|
185
|
+
if (entry === undefined) {
|
|
186
|
+
throw new TransportError('WT_UNKNOWN_EVENT', `'${event}' is not in the contract`, 'Add it to the contract, or check the spelling.');
|
|
187
|
+
}
|
|
188
|
+
const bytes = encodePayload(payload);
|
|
189
|
+
if (entry.lane === 'datagram') {
|
|
190
|
+
const seq = ((this.#sequences.get(entry.id) ?? 0) + 1) >>> 0 || 1;
|
|
191
|
+
this.#sequences.set(entry.id, seq);
|
|
192
|
+
const dg = encodeDatagram({ eventId: entry.id, origin: this.#origin, sequence: seq, payload: bytes }, this.#conn.maxDatagramSize());
|
|
193
|
+
this.#dgQueue.push(dg, this.#now());
|
|
194
|
+
this.#flushDatagrams();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.sendFrame({
|
|
198
|
+
type: FrameType.EMIT,
|
|
199
|
+
codec: Codec.JSON,
|
|
200
|
+
eventId: entry.id,
|
|
201
|
+
payload: bytes,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** Register a responder. Only events declaring `returns` are callable. */
|
|
205
|
+
handle(event, handler) {
|
|
206
|
+
// D1 at the registration point, which is the one that actually turns a droppable
|
|
207
|
+
// message into an acknowledged one. Guarding `call()` alone would leave a responder
|
|
208
|
+
// happily answering over a bidirectional stream for an event whose contract says the
|
|
209
|
+
// message may be dropped.
|
|
210
|
+
const entry = this.#table.byName(event);
|
|
211
|
+
if (entry !== undefined && entry.lane === 'datagram') {
|
|
212
|
+
throw new TransportError('WT_PROTOCOL_ERROR', `'${event}' is a datagram event, so it has no response path to handle`, 'Move the event to the stream lane and give it `returns`, or handle it with on().');
|
|
213
|
+
}
|
|
214
|
+
this.#callHandlers.set(event, handler);
|
|
215
|
+
return () => {
|
|
216
|
+
this.#callHandlers.delete(event);
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** Revoke a responder on this session. `Server.handle`'s disposer needs it. */
|
|
220
|
+
unhandle(event) {
|
|
221
|
+
this.#callHandlers.delete(event);
|
|
222
|
+
}
|
|
223
|
+
/** True once the connection has closed and everything it held has been released. */
|
|
224
|
+
get disposed() {
|
|
225
|
+
return this.#disposed;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Each call opens its own bidirectional stream, so the stream IS the correlation: no
|
|
229
|
+
* identifiers, no pending map, and a stalled call blocks nothing else.
|
|
230
|
+
*
|
|
231
|
+
* There is no default timeout. A dead peer is detected by the QUIC idle timeout, which
|
|
232
|
+
* closes the session and rejects every pending call — the case a timeout is usually
|
|
233
|
+
* reached for is already handled. Pass `AbortSignal.timeout(ms)` for a slow but live
|
|
234
|
+
* responder.
|
|
235
|
+
*/
|
|
236
|
+
async call(event, payload, opts) {
|
|
237
|
+
const entry = this.#table.byName(event);
|
|
238
|
+
if (entry === undefined) {
|
|
239
|
+
throw new TransportError('WT_UNKNOWN_EVENT', `'${event}' is not in the contract`, 'Add it to the contract, or check the spelling.');
|
|
240
|
+
}
|
|
241
|
+
// An already-aborted signal must not open a stream just to tear it down.
|
|
242
|
+
// §11: a peer that has detected its counterpart is gone MUST NOT reuse a stream from
|
|
243
|
+
// that session. Opening a new one on a dead session is the same mistake wearing a
|
|
244
|
+
// different hat — the transport may even accept it, and the call then hangs.
|
|
245
|
+
if (this.#disposed) {
|
|
246
|
+
throw new TransportError('WT_SESSION_CLOSED', 'the session is closed, so no call stream can be opened on it', 'Reconnect. A reconnect is a new session and does not restore room membership (D4).');
|
|
247
|
+
}
|
|
248
|
+
if (opts?.signal?.aborted === true)
|
|
249
|
+
throw abortToTransportError(opts.signal.reason);
|
|
250
|
+
// An event that declares no `returns` has no response to wait for. The type system
|
|
251
|
+
// already excludes it from `CallableOf`; this is the same refusal for a caller that
|
|
252
|
+
// reached the wire without the types — and it names the actual problem instead of
|
|
253
|
+
// travelling to the responder to come back as "no handler registered", which is a
|
|
254
|
+
// different fault with a different remedy.
|
|
255
|
+
if (entry.lane === 'stream' && entry.def.returns === undefined) {
|
|
256
|
+
throw new TransportError('WT_UNKNOWN_EVENT', `'${event}' declares no \`returns\`, so there is nothing to await`, 'Add `returns` to the event in the contract, or use emit() if it is fire-and-forget.');
|
|
257
|
+
}
|
|
258
|
+
if (entry.lane === 'datagram') {
|
|
259
|
+
throw new TransportError('WT_PROTOCOL_ERROR', `'${event}' is a datagram event and cannot be called`, 'A datagram may be dropped, so there is no response to await. Use emit(), or move the event to the stream lane.');
|
|
260
|
+
}
|
|
261
|
+
if (this.#openCalls >= MAX_CONCURRENT_CALL_STREAMS) {
|
|
262
|
+
throw new TransportError('WT_TOO_MANY_STREAMS', `${this.#openCalls} call streams are already open on this session`, `Reduce concurrency below ${MAX_CONCURRENT_CALL_STREAMS} and retry; the session stays open.`);
|
|
263
|
+
}
|
|
264
|
+
this.#openCalls++;
|
|
265
|
+
try {
|
|
266
|
+
return await this.#doCall(entry.id, encodePayload(payload), opts?.signal);
|
|
267
|
+
}
|
|
268
|
+
catch (e) {
|
|
269
|
+
// D18 removes the default call timeout on the grounds that `AbortSignal.timeout(ms)`
|
|
270
|
+
// is the documented substitute, so aborting is the most-documented failure this
|
|
271
|
+
// library has — and it rejected with a raw DOMException carrying no code and no
|
|
272
|
+
// remedy, which the error helper printed in API.md reports as 'unknown'.
|
|
273
|
+
// Read through a call so narrowing from the pre-check above does not apply: the
|
|
274
|
+
// signal can abort at any point during the call, which is the whole reason it exists.
|
|
275
|
+
const abortedNow = () => opts?.signal?.aborted ?? false;
|
|
276
|
+
throw isAbort(e) || abortedNow() ? abortToTransportError(e) : e;
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
this.#openCalls--;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
get openCalls() {
|
|
283
|
+
return this.#openCalls;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The cap is a receiver-side refusal or it is nothing. `call()` declining to open a
|
|
287
|
+
* 257th stream protects the peer from us; it does nothing about a peer that opens 10,000
|
|
288
|
+
* — a Go implementation written from PROTOCOL.md, or a browser calling
|
|
289
|
+
* `createBidirectionalStream()` directly. That is the case §10.1 code 9 exists for.
|
|
290
|
+
*
|
|
291
|
+
* Refused before the request is read, deliberately: the cost this bound exists to bound
|
|
292
|
+
* is the decoder, the handler and the 16 MiB the decoder will buffer, all of which come
|
|
293
|
+
* after the first read.
|
|
294
|
+
*/
|
|
295
|
+
#acceptCall(stream) {
|
|
296
|
+
if (this.#inboundCalls >= MAX_CONCURRENT_CALL_STREAMS) {
|
|
297
|
+
const refusal = new Error(`code:${ResetCode.WT_TOO_MANY_STREAMS}`);
|
|
298
|
+
void stream.writable.abort(refusal).catch(() => undefined);
|
|
299
|
+
void stream.readable.cancel(refusal).catch(() => undefined);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
this.#inboundCalls++;
|
|
303
|
+
void this.#serveCall(stream).finally(() => {
|
|
304
|
+
this.#inboundCalls--;
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
get inboundCalls() {
|
|
308
|
+
return this.#inboundCalls;
|
|
309
|
+
}
|
|
310
|
+
/** Used by the hub to forward an already-encoded frame without re-encoding per peer. */
|
|
311
|
+
sendFrame(frame) {
|
|
312
|
+
this.sendEncodedFrame(encodeFrame(frame));
|
|
313
|
+
}
|
|
314
|
+
/** Forward an already-encoded frame. The hub encodes once and fans the same bytes out. */
|
|
315
|
+
sendEncodedFrame(bytes) {
|
|
316
|
+
try {
|
|
317
|
+
this.#emitQueue.push(bytes);
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
if (e instanceof PeerTooSlowError) {
|
|
321
|
+
this.close(CloseCode.WT_PEER_TOO_SLOW, e.message);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
throw e;
|
|
325
|
+
}
|
|
326
|
+
this.#flushEmits();
|
|
327
|
+
}
|
|
328
|
+
sendDatagramBytes(bytes) {
|
|
329
|
+
this.#dgQueue.push(bytes, this.#now());
|
|
330
|
+
this.#flushDatagrams();
|
|
331
|
+
}
|
|
332
|
+
stats() {
|
|
333
|
+
return { ...this.#dgQueue.stats(), staleReceived: this.#gate.staleReceived };
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The only door out of a session, and every internal path now uses it.
|
|
337
|
+
*
|
|
338
|
+
* Guarding this method alone was not enough: four call sites reached `#conn.close()`
|
|
339
|
+
* directly — the handshake deadline, the peer-too-slow bound, an emit write failure and a
|
|
340
|
+
* protocol error on the read loop — so the guard covered the one path that already had
|
|
341
|
+
* the fewest duplicates. A soak still produced 619,422 `close sent twice` complaints from
|
|
342
|
+
* quiche after the first fix, which is what a partial guard looks like from the outside.
|
|
343
|
+
*/
|
|
344
|
+
close(code, reason) {
|
|
345
|
+
// Idempotent in both halves. `dispose()` already was; `conn.close()` was not, so a
|
|
346
|
+
// second close — a client disconnecting while the server is tearing the same session
|
|
347
|
+
// down, which is ordinary — reached the transport twice. quiche logs
|
|
348
|
+
// "WebTransportHttp3 close sent twice" and refuses it, which is a protocol-level
|
|
349
|
+
// complaint we were generating and then ignoring.
|
|
350
|
+
if (this.#disposed)
|
|
351
|
+
return;
|
|
352
|
+
this.dispose();
|
|
353
|
+
this.#conn.close(code, reason);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Idempotent, and wired to `conn.closed` in `start()` so it cannot be forgotten.
|
|
357
|
+
*
|
|
358
|
+
* It was forgotten. `clearInterval` appeared in exactly one place — `close()` — and
|
|
359
|
+
* neither teardown path called it: the server's `conn.closed` continuation freed the
|
|
360
|
+
* origin and removed the peer, and the client's patched a snapshot. Whichever side did
|
|
361
|
+
* not *initiate* the close kept a live interval whose callback closes over `this`,
|
|
362
|
+
* retaining the Session, its Connection, the frame decoder, both queues, the sequence
|
|
363
|
+
* gate and every handler set. At 100 sessions a second that is 360,000 unreclaimable
|
|
364
|
+
* Sessions an hour, and `unref()` does nothing about it — it stops a timer holding the
|
|
365
|
+
* event loop open, not holding memory.
|
|
366
|
+
*/
|
|
367
|
+
dispose() {
|
|
368
|
+
if (this.#disposed)
|
|
369
|
+
return;
|
|
370
|
+
this.#disposed = true;
|
|
371
|
+
if (this.#sweepTimer !== undefined)
|
|
372
|
+
clearInterval(this.#sweepTimer);
|
|
373
|
+
this.#sweepTimer = undefined;
|
|
374
|
+
this.#handlers.clear();
|
|
375
|
+
this.#callHandlers.clear();
|
|
376
|
+
this.#controlHandlers.clear();
|
|
377
|
+
this.#writer = undefined;
|
|
378
|
+
}
|
|
379
|
+
// ------------------------------------------------------------------ calls
|
|
380
|
+
async #doCall(eventId, body, signal) {
|
|
381
|
+
const stream = await this.#conn.openBidi();
|
|
382
|
+
const writer = stream.writable.getWriter();
|
|
383
|
+
const reader = stream.readable.getReader();
|
|
384
|
+
// Abort maps to a QUIC stream reset: immediate, and costing no application message.
|
|
385
|
+
// On a WebSocket this would need an app-level protocol and the peer would keep
|
|
386
|
+
// sending until it heard us.
|
|
387
|
+
const onAbort = () => {
|
|
388
|
+
void writer.abort(new Error(`code:${ResetCode.WT_ABORTED}`)).catch(() => undefined);
|
|
389
|
+
void reader.cancel(new Error(`code:${ResetCode.WT_ABORTED}`)).catch(() => undefined);
|
|
390
|
+
};
|
|
391
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
392
|
+
try {
|
|
393
|
+
await writer.write(encodeFrame({
|
|
394
|
+
type: FrameType.CALL_REQUEST,
|
|
395
|
+
codec: Codec.JSON,
|
|
396
|
+
eventId,
|
|
397
|
+
payload: body,
|
|
398
|
+
}));
|
|
399
|
+
// Half-close: FIN ends the request while the read side stays open.
|
|
400
|
+
await writer.close();
|
|
401
|
+
const decoder = new FrameDecoder();
|
|
402
|
+
const responses = [];
|
|
403
|
+
for (;;) {
|
|
404
|
+
const { value, done } = await reader.read();
|
|
405
|
+
if (done)
|
|
406
|
+
break;
|
|
407
|
+
if (value === undefined)
|
|
408
|
+
continue;
|
|
409
|
+
for (const f of decoder.push(value))
|
|
410
|
+
responses.push(f);
|
|
411
|
+
}
|
|
412
|
+
signal?.throwIfAborted();
|
|
413
|
+
const error = responses.find((f) => f.type === FrameType.CALL_ERROR);
|
|
414
|
+
if (error !== undefined) {
|
|
415
|
+
const body_ = decodePayload(error.payload);
|
|
416
|
+
throw new TransportError((body_.code ?? 'WT_HANDLER_ERROR'), body_.message ?? 'the responder returned an error', 'Inspect the responder. The code is the one it chose.');
|
|
417
|
+
}
|
|
418
|
+
const first = responses.find((f) => f.type === FrameType.CALL_RESPONSE);
|
|
419
|
+
if (first === undefined) {
|
|
420
|
+
throw new TransportError('WT_PROTOCOL_ERROR', 'the responder closed the stream without a response frame', 'A responder must write exactly one CALL_RESPONSE or one CALL_ERROR.');
|
|
421
|
+
}
|
|
422
|
+
return decodePayload(first.payload);
|
|
423
|
+
}
|
|
424
|
+
finally {
|
|
425
|
+
signal?.removeEventListener('abort', onAbort);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async #serveCall(stream) {
|
|
429
|
+
const reader = stream.readable.getReader();
|
|
430
|
+
const writer = stream.writable.getWriter();
|
|
431
|
+
const controller = new AbortController();
|
|
432
|
+
const decoder = new FrameDecoder();
|
|
433
|
+
let request;
|
|
434
|
+
try {
|
|
435
|
+
for (;;) {
|
|
436
|
+
const { value, done } = await reader.read();
|
|
437
|
+
if (done)
|
|
438
|
+
break; // the initiator half-closed: the request is complete
|
|
439
|
+
if (value === undefined)
|
|
440
|
+
continue;
|
|
441
|
+
for (const f of decoder.push(value))
|
|
442
|
+
if (request === undefined)
|
|
443
|
+
request = f;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// A reset before the request completed is a cancellation, not a fault.
|
|
448
|
+
controller.abort();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
// The request is fully read at this point, so nothing is watching the stream any
|
|
452
|
+
// more — which is why an abort never reached the handler. The initiator's abort
|
|
453
|
+
// resets its send side AND cancels its read side, and that STOP_SENDING surfaces here
|
|
454
|
+
// as a rejection on our writer. Watch it, or `ctx.signal` is decoration.
|
|
455
|
+
void writer.closed.catch(() => controller.abort());
|
|
456
|
+
if (request === undefined || request.type !== FrameType.CALL_REQUEST) {
|
|
457
|
+
await this.#failCall(writer, 'WT_PROTOCOL_ERROR', 'expected a CALL_REQUEST frame');
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (this.#negotiated === undefined) {
|
|
461
|
+
// A call racing the handshake resets its own stream, not the session.
|
|
462
|
+
await this.#failCall(writer, 'WT_HANDSHAKE_INCOMPLETE', 'the handshake has not completed');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const entry = this.#table.byId(request.eventId);
|
|
466
|
+
if (entry === undefined) {
|
|
467
|
+
await this.#failCall(writer, 'WT_UNKNOWN_EVENT', `event id ${request.eventId} is not in the contract`);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (entry.lane === 'datagram') {
|
|
471
|
+
// A peer is not bound by our types. A second implementation written from
|
|
472
|
+
// PROTOCOL.md can open a bidirectional stream for any event id it likes, and
|
|
473
|
+
// answering one for a datagram event would silently upgrade a droppable message to a
|
|
474
|
+
// guaranteed one on this side of the wire.
|
|
475
|
+
await this.#failCall(writer, 'WT_PROTOCOL_ERROR', `event '${entry.name}' is on the datagram lane and is not callable`);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const handler = this.#callHandlers.get(entry.name);
|
|
479
|
+
if (handler === undefined) {
|
|
480
|
+
await this.#failCall(writer, 'WT_UNKNOWN_EVENT', `no handler registered for '${entry.name}'`);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
let value = decodePayload(request.payload);
|
|
485
|
+
if (this.#validateInbound)
|
|
486
|
+
value = await validate(entry.def.payload, value);
|
|
487
|
+
const result = await handler(value, { signal: controller.signal });
|
|
488
|
+
await writer.write(encodeFrame({
|
|
489
|
+
type: FrameType.CALL_RESPONSE,
|
|
490
|
+
codec: Codec.JSON,
|
|
491
|
+
eventId: EVENT_ID_NOT_APPLICABLE,
|
|
492
|
+
payload: encodePayload(result),
|
|
493
|
+
}));
|
|
494
|
+
await writer.close();
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
const code = e instanceof TransportError ? e.code : 'WT_HANDLER_ERROR';
|
|
498
|
+
await this.#failCall(writer, code, e instanceof Error ? e.message : String(e));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async #failCall(writer, code, message) {
|
|
502
|
+
try {
|
|
503
|
+
await writer.write(encodeFrame({
|
|
504
|
+
type: FrameType.CALL_ERROR,
|
|
505
|
+
codec: Codec.JSON,
|
|
506
|
+
eventId: EVENT_ID_NOT_APPLICABLE,
|
|
507
|
+
payload: encodePayload({ code, message: message.slice(0, 1024) }),
|
|
508
|
+
}));
|
|
509
|
+
await writer.close();
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
// The peer already went away; nothing left to report to.
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// ------------------------------------------------------------------ internals
|
|
516
|
+
/**
|
|
517
|
+
* Coalesced, never synchronous. A burst of emits inside one turn accumulates in the
|
|
518
|
+
* bounded ring, so drop-oldest applies; and because the TTL is checked at drain, a
|
|
519
|
+
* flush delayed past it discards what has gone stale rather than delivering history.
|
|
520
|
+
*/
|
|
521
|
+
#flushDatagrams() {
|
|
522
|
+
if (this.#flushScheduled)
|
|
523
|
+
return;
|
|
524
|
+
this.#flushScheduled = true;
|
|
525
|
+
this.#schedule(() => {
|
|
526
|
+
this.#flushScheduled = false;
|
|
527
|
+
for (const dg of this.#dgQueue.drain(this.#now()))
|
|
528
|
+
this.#conn.sendDatagram(dg);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* One write in flight at a time, and the frame stays in the queue until that write
|
|
533
|
+
* *completes*. That is the whole fix: previously this drained the entire queue on the
|
|
534
|
+
* same turn as the push and appended each frame to an unbounded promise chain, so depth
|
|
535
|
+
* returned to zero after every push and `EmitQueue`'s bound could never be reached from
|
|
536
|
+
* a Session. The backlog did not go away, it went somewhere that could not disconnect
|
|
537
|
+
* anyone — and whose `.catch(() => undefined)` discarded every write failure on the lane
|
|
538
|
+
* that advertises reliable ordered delivery.
|
|
539
|
+
*
|
|
540
|
+
* Nothing flushes before the handshake, so frame 0 keeps its position by construction
|
|
541
|
+
* and a burst arriving mid-handshake accumulates against the bound rather than racing it.
|
|
542
|
+
*/
|
|
543
|
+
#flushEmits() {
|
|
544
|
+
if (this.#writing || !this.#handshakeSent)
|
|
545
|
+
return;
|
|
546
|
+
const w = this.#writer;
|
|
547
|
+
if (w === undefined)
|
|
548
|
+
return;
|
|
549
|
+
const next = this.#emitQueue.peek();
|
|
550
|
+
if (next === undefined)
|
|
551
|
+
return;
|
|
552
|
+
this.#writing = true;
|
|
553
|
+
void w.write(next).then(() => {
|
|
554
|
+
this.#emitQueue.shift();
|
|
555
|
+
this.#writing = false;
|
|
556
|
+
this.#flushEmits();
|
|
557
|
+
}, (e) => {
|
|
558
|
+
this.#writing = false;
|
|
559
|
+
// §5.5: one emit stream per direction and no way to reopen it, so a fault on it is
|
|
560
|
+
// fatal to the lane. Swallowing it left the lane silently dead while `getSnapshot()`
|
|
561
|
+
// still reported `connected`.
|
|
562
|
+
this.close(CloseCode.WT_PROTOCOL_ERROR, closeReason(e));
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/** Frames written to the contract but not yet accepted by the transport. */
|
|
566
|
+
get emitQueueDepth() {
|
|
567
|
+
return this.#emitQueue.depth;
|
|
568
|
+
}
|
|
569
|
+
async #readEmitStream(readable) {
|
|
570
|
+
const decoder = new FrameDecoder();
|
|
571
|
+
const reader = readable.getReader();
|
|
572
|
+
try {
|
|
573
|
+
for (;;) {
|
|
574
|
+
const { value, done } = await reader.read();
|
|
575
|
+
if (done)
|
|
576
|
+
break;
|
|
577
|
+
if (value === undefined)
|
|
578
|
+
continue;
|
|
579
|
+
for (const frame of decoder.push(value))
|
|
580
|
+
await this.#onFrame(frame);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
catch (e) {
|
|
584
|
+
// A protocol error on the emit stream is fatal to the lane: there is one stream per
|
|
585
|
+
// direction and no way to reopen it, so resetting would end stream traffic silently.
|
|
586
|
+
this.#handshakeReject(e);
|
|
587
|
+
this.close(closeCodeFor(e), closeReason(e));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
async #onFrame(frame) {
|
|
591
|
+
if (frame.type === FrameType.HANDSHAKE) {
|
|
592
|
+
const peer = parseHandshake(decodePayload(frame.payload));
|
|
593
|
+
const n = negotiate(buildHandshake(this.#table), peer);
|
|
594
|
+
this.#negotiated = n;
|
|
595
|
+
this.#handshakeResolve(n);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (this.#negotiated === undefined) {
|
|
599
|
+
throw new TransportError('WT_HANDSHAKE_INCOMPLETE', `a ${frame.type} frame arrived before the handshake`, 'The handshake is frame 0 of the emit stream. Await connect() before sending.');
|
|
600
|
+
}
|
|
601
|
+
if (frame.type === FrameType.EMIT) {
|
|
602
|
+
await this.#deliver(frame.eventId, frame.payload, this.#origin);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (frame.type === FrameType.JOIN || frame.type === FrameType.LEAVE) {
|
|
606
|
+
const body = decodePayload(frame.payload);
|
|
607
|
+
for (const cb of this.#controlHandlers)
|
|
608
|
+
cb(frame.type, body);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
#onDatagram(bytes) {
|
|
612
|
+
// PROTOCOL.md §7 and ADR 0009: a datagram before the handshake is discarded silently.
|
|
613
|
+
// The stream lane has had this guard all along; the datagram lane had none, so an
|
|
614
|
+
// early packet was decoded and handed to the application for a session whose contract
|
|
615
|
+
// had not been agreed. A second implementation drops it, and this one rendered it.
|
|
616
|
+
if (this.#negotiated === undefined)
|
|
617
|
+
return;
|
|
618
|
+
void (async () => {
|
|
619
|
+
try {
|
|
620
|
+
const dg = decodeDatagram(bytes);
|
|
621
|
+
if (!this.#gate.accept(dg.origin, dg.eventId, dg.sequence, this.#now()))
|
|
622
|
+
return;
|
|
623
|
+
await this.#deliver(dg.eventId, dg.payload, dg.origin);
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
// A malformed datagram is discarded. The lane already permits loss, so raising a
|
|
627
|
+
// session-level fault over one bad packet would be a worse trade.
|
|
628
|
+
}
|
|
629
|
+
})();
|
|
630
|
+
}
|
|
631
|
+
async #deliver(eventId, payload, from) {
|
|
632
|
+
const entry = this.#table.byId(eventId);
|
|
633
|
+
if (entry === undefined)
|
|
634
|
+
return; // peers on adjacent contracts legitimately differ
|
|
635
|
+
const handlers = this.#handlers.get(entry.name);
|
|
636
|
+
if (handlers === undefined || handlers.size === 0)
|
|
637
|
+
return;
|
|
638
|
+
let value = decodePayload(payload);
|
|
639
|
+
if (this.#validateInbound)
|
|
640
|
+
value = await validate(entry.def.payload, value);
|
|
641
|
+
for (const h of handlers)
|
|
642
|
+
h(value, { from });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
//# sourceMappingURL=session.js.map
|