@johnhenry/browsermesh-transport 0.0.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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/package.json +31 -0
- package/src/channel-relay.mjs +225 -0
- package/src/cross-origin.mjs +543 -0
- package/src/gateway.mjs +627 -0
- package/src/index.mjs +12 -0
- package/src/relay.mjs +653 -0
- package/src/silent-catch.mjs +55 -0
- package/src/streams.mjs +627 -0
- package/src/transport.mjs +357 -0
- package/src/webrtc.mjs +773 -0
- package/src/websocket.mjs +1082 -0
- package/src/webtransport.mjs +216 -0
- package/src/wisp-client.mjs +747 -0
- package/src/wisp.mjs +348 -0
- package/src/wsh-bridge.mjs +242 -0
package/src/streams.mjs
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
// STATUS: INTEGRATED — wired into ClawserPod lifecycle, proven via E2E testing
|
|
3
|
+
* Clawser Mesh Streams
|
|
4
|
+
*
|
|
5
|
+
* Multiplexed data streaming with credit-based backpressure.
|
|
6
|
+
* Wraps BrowserMesh streaming-protocol.md spec (0x12-0x16 wire codes)
|
|
7
|
+
* with a higher-level application API.
|
|
8
|
+
*
|
|
9
|
+
* @module clawser-mesh-streams
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { MESH_TYPE, MESH_ERROR } from '@johnhenry/browsermesh-primitives';
|
|
13
|
+
|
|
14
|
+
// ── Constants ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export const STREAM_STATES = Object.freeze([
|
|
17
|
+
'IDLE', 'OPEN', 'HALF_CLOSED_LOCAL', 'HALF_CLOSED_REMOTE', 'CLOSED',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export const STREAM_ERROR_CODES = Object.freeze([
|
|
21
|
+
'CANCELLED', 'TIMEOUT', 'FLOW_CONTROL', 'TOO_LARGE', 'INTERNAL',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export const STREAM_DEFAULTS = Object.freeze({
|
|
25
|
+
initialCredits: 8,
|
|
26
|
+
maxCredits: 64,
|
|
27
|
+
idleTimeout: 30_000,
|
|
28
|
+
maxStreamSize: 256 * 1024 * 1024,
|
|
29
|
+
maxConcurrentStreams: 16,
|
|
30
|
+
maxChunkSize: 16_384,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
let _idCounter = 0;
|
|
36
|
+
|
|
37
|
+
function generateStreamId() {
|
|
38
|
+
// 16-byte ID: 8 random + 4 timestamp + 4 counter
|
|
39
|
+
const id = new Uint8Array(16);
|
|
40
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
41
|
+
crypto.getRandomValues(id);
|
|
42
|
+
} else {
|
|
43
|
+
for (let i = 0; i < 16; i++) id[i] = (Math.random() * 256) | 0;
|
|
44
|
+
}
|
|
45
|
+
const view = new DataView(id.buffer);
|
|
46
|
+
view.setUint32(12, ++_idCounter, false);
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function streamIdToHex(id) {
|
|
51
|
+
if (typeof id === 'string') return id;
|
|
52
|
+
return Array.from(id).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Valid state transitions: { fromState: [toState, ...] }
|
|
56
|
+
const VALID_TRANSITIONS = {
|
|
57
|
+
IDLE: ['OPEN', 'CLOSED'],
|
|
58
|
+
OPEN: ['HALF_CLOSED_LOCAL', 'HALF_CLOSED_REMOTE', 'CLOSED'],
|
|
59
|
+
HALF_CLOSED_LOCAL: ['CLOSED'],
|
|
60
|
+
HALF_CLOSED_REMOTE: ['CLOSED'],
|
|
61
|
+
CLOSED: [],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ── MeshStream ───────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A single multiplexed stream with state machine, flow control,
|
|
68
|
+
* and callback-based data delivery.
|
|
69
|
+
*/
|
|
70
|
+
export class MeshStream {
|
|
71
|
+
#id;
|
|
72
|
+
#hexId;
|
|
73
|
+
#state = 'IDLE';
|
|
74
|
+
#method;
|
|
75
|
+
#ordered;
|
|
76
|
+
#encrypted;
|
|
77
|
+
#metadata;
|
|
78
|
+
#initiator;
|
|
79
|
+
#sendSeq = 0;
|
|
80
|
+
#recvSeq = 0;
|
|
81
|
+
#sendCredits;
|
|
82
|
+
#recvCredits;
|
|
83
|
+
#bytesSent = 0;
|
|
84
|
+
#bytesReceived = 0;
|
|
85
|
+
#framesSent = 0;
|
|
86
|
+
#framesReceived = 0;
|
|
87
|
+
#createdAt;
|
|
88
|
+
#closedAt = null;
|
|
89
|
+
#maxSize;
|
|
90
|
+
|
|
91
|
+
// Callbacks
|
|
92
|
+
#onData = null;
|
|
93
|
+
#onEnd = null;
|
|
94
|
+
#onError = null;
|
|
95
|
+
#onCredits = null;
|
|
96
|
+
|
|
97
|
+
// Send queue + credit wait
|
|
98
|
+
#sendQueue = [];
|
|
99
|
+
#creditResolvers = [];
|
|
100
|
+
|
|
101
|
+
// Multiplexer back-reference for sending
|
|
102
|
+
#mux = null;
|
|
103
|
+
|
|
104
|
+
constructor(opts = {}) {
|
|
105
|
+
this.#id = opts.id || generateStreamId();
|
|
106
|
+
this.#hexId = streamIdToHex(this.#id);
|
|
107
|
+
this.#method = opts.method || '';
|
|
108
|
+
this.#ordered = opts.ordered !== false;
|
|
109
|
+
this.#encrypted = opts.encrypted === true;
|
|
110
|
+
this.#metadata = opts.metadata || {};
|
|
111
|
+
this.#initiator = opts.initiator === true;
|
|
112
|
+
this.#sendCredits = opts.initialCredits ?? STREAM_DEFAULTS.initialCredits;
|
|
113
|
+
this.#recvCredits = opts.initialCredits ?? STREAM_DEFAULTS.initialCredits;
|
|
114
|
+
this.#maxSize = opts.maxSize ?? STREAM_DEFAULTS.maxStreamSize;
|
|
115
|
+
this.#createdAt = opts.createdAt ?? Date.now();
|
|
116
|
+
this.#mux = opts.multiplexer || null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Accessors ────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
get id() { return this.#id; }
|
|
122
|
+
get hexId() { return this.#hexId; }
|
|
123
|
+
get state() { return this.#state; }
|
|
124
|
+
get method() { return this.#method; }
|
|
125
|
+
get ordered() { return this.#ordered; }
|
|
126
|
+
get encrypted() { return this.#encrypted; }
|
|
127
|
+
get metadata() { return this.#metadata; }
|
|
128
|
+
get initiator() { return this.#initiator; }
|
|
129
|
+
get sendCredits() { return this.#sendCredits; }
|
|
130
|
+
get recvCredits() { return this.#recvCredits; }
|
|
131
|
+
get sendSeq() { return this.#sendSeq; }
|
|
132
|
+
get recvSeq() { return this.#recvSeq; }
|
|
133
|
+
|
|
134
|
+
// ── Callbacks ────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
onData(cb) { this.#onData = cb; return this; }
|
|
137
|
+
onEnd(cb) { this.#onEnd = cb; return this; }
|
|
138
|
+
onError(cb) { this.#onError = cb; return this; }
|
|
139
|
+
onCredits(cb) { this.#onCredits = cb; return this; }
|
|
140
|
+
|
|
141
|
+
// ── State transitions ────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/** @internal Transition state with validation */
|
|
144
|
+
_transition(newState) {
|
|
145
|
+
const allowed = VALID_TRANSITIONS[this.#state];
|
|
146
|
+
if (!allowed || !allowed.includes(newState)) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Invalid stream state transition: ${this.#state} → ${newState}`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
this.#state = newState;
|
|
152
|
+
if (newState === 'CLOSED') {
|
|
153
|
+
this.#closedAt = Date.now();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @internal Set state to OPEN (from IDLE) */
|
|
158
|
+
_open() {
|
|
159
|
+
this._transition('OPEN');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Writing ──────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Write data to the stream. Respects credit-based flow control.
|
|
166
|
+
* @param {Uint8Array|string} data
|
|
167
|
+
* @returns {boolean} true if written immediately, false if queued
|
|
168
|
+
*/
|
|
169
|
+
write(data) {
|
|
170
|
+
if (this.#state !== 'OPEN' && this.#state !== 'HALF_CLOSED_REMOTE') {
|
|
171
|
+
throw new Error(`Cannot write in state ${this.#state}`);
|
|
172
|
+
}
|
|
173
|
+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
174
|
+
if (this.#bytesSent + bytes.length > this.#maxSize) {
|
|
175
|
+
throw new Error(`Stream size limit exceeded (max ${this.#maxSize})`);
|
|
176
|
+
}
|
|
177
|
+
if (this.#sendCredits > 0) {
|
|
178
|
+
this.#sendCredits--;
|
|
179
|
+
this.#sendSeq++;
|
|
180
|
+
this.#bytesSent += bytes.length;
|
|
181
|
+
this.#framesSent++;
|
|
182
|
+
if (this.#mux) {
|
|
183
|
+
this.#mux._sendData(this.#hexId, bytes, this.#sendSeq);
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
// Queue for when credits arrive
|
|
188
|
+
this.#sendQueue.push(bytes);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Close the local side of the stream (half-close).
|
|
194
|
+
*/
|
|
195
|
+
end() {
|
|
196
|
+
if (this.#state === 'CLOSED' || this.#state === 'HALF_CLOSED_LOCAL') return;
|
|
197
|
+
if (this.#state === 'HALF_CLOSED_REMOTE') {
|
|
198
|
+
this._transition('CLOSED');
|
|
199
|
+
} else if (this.#state === 'OPEN') {
|
|
200
|
+
this._transition('HALF_CLOSED_LOCAL');
|
|
201
|
+
} else {
|
|
202
|
+
// IDLE — just close
|
|
203
|
+
this._transition('CLOSED');
|
|
204
|
+
}
|
|
205
|
+
if (this.#mux) {
|
|
206
|
+
this.#mux._sendEnd(this.#hexId, this.#bytesSent);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Cancel the stream with an optional reason.
|
|
212
|
+
* @param {string} [reason]
|
|
213
|
+
*/
|
|
214
|
+
cancel(reason) {
|
|
215
|
+
if (this.#state === 'CLOSED') return;
|
|
216
|
+
const prevState = this.#state;
|
|
217
|
+
this.#state = 'CLOSED';
|
|
218
|
+
this.#closedAt = Date.now();
|
|
219
|
+
this.#sendQueue.length = 0;
|
|
220
|
+
// Reject pending credit waiters
|
|
221
|
+
for (const r of this.#creditResolvers) {
|
|
222
|
+
r.reject(new Error('Stream cancelled'));
|
|
223
|
+
}
|
|
224
|
+
this.#creditResolvers.length = 0;
|
|
225
|
+
if (this.#mux && prevState !== 'IDLE') {
|
|
226
|
+
this.#mux._sendError(this.#hexId, 'CANCELLED', reason || 'Stream cancelled');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Flow control ─────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Grant additional credits to the remote sender.
|
|
234
|
+
* @param {number} n - Number of credits to grant
|
|
235
|
+
*/
|
|
236
|
+
grantCredits(n) {
|
|
237
|
+
if (n <= 0 || !Number.isFinite(n)) throw new Error('Credits must be positive');
|
|
238
|
+
if (this.#state === 'CLOSED') return;
|
|
239
|
+
const newCredits = this.#recvCredits + n;
|
|
240
|
+
if (newCredits > STREAM_DEFAULTS.maxCredits) {
|
|
241
|
+
throw new Error(`Credits would exceed max (${STREAM_DEFAULTS.maxCredits})`);
|
|
242
|
+
}
|
|
243
|
+
this.#recvCredits = newCredits;
|
|
244
|
+
if (this.#mux) {
|
|
245
|
+
this.#mux._sendWindowUpdate(this.#hexId, n);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @internal Receive credits from remote side (window update).
|
|
251
|
+
* @param {number} n
|
|
252
|
+
*/
|
|
253
|
+
_receiveCredits(n) {
|
|
254
|
+
this.#sendCredits += n;
|
|
255
|
+
if (this.#sendCredits > STREAM_DEFAULTS.maxCredits) {
|
|
256
|
+
this.#sendCredits = STREAM_DEFAULTS.maxCredits;
|
|
257
|
+
}
|
|
258
|
+
if (this.#onCredits) this.#onCredits(this.#sendCredits);
|
|
259
|
+
// Drain queued writes
|
|
260
|
+
while (this.#sendQueue.length > 0 && this.#sendCredits > 0) {
|
|
261
|
+
const bytes = this.#sendQueue.shift();
|
|
262
|
+
this.#sendCredits--;
|
|
263
|
+
this.#sendSeq++;
|
|
264
|
+
this.#bytesSent += bytes.length;
|
|
265
|
+
this.#framesSent++;
|
|
266
|
+
if (this.#mux) {
|
|
267
|
+
this.#mux._sendData(this.#hexId, bytes, this.#sendSeq);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Receive path (called by multiplexer) ─────────────────────────
|
|
273
|
+
|
|
274
|
+
/** @internal Handle inbound data frame */
|
|
275
|
+
_receiveData(data, seq) {
|
|
276
|
+
if (this.#state !== 'OPEN' && this.#state !== 'HALF_CLOSED_LOCAL') {
|
|
277
|
+
return; // Drop data in wrong state
|
|
278
|
+
}
|
|
279
|
+
this.#recvSeq = seq;
|
|
280
|
+
this.#bytesReceived += data.length;
|
|
281
|
+
this.#framesReceived++;
|
|
282
|
+
if (this.#onData) this.#onData(data, seq);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** @internal Handle inbound end */
|
|
286
|
+
_receiveEnd() {
|
|
287
|
+
if (this.#state === 'CLOSED') return;
|
|
288
|
+
if (this.#state === 'HALF_CLOSED_LOCAL') {
|
|
289
|
+
this._transition('CLOSED');
|
|
290
|
+
} else if (this.#state === 'OPEN') {
|
|
291
|
+
this._transition('HALF_CLOSED_REMOTE');
|
|
292
|
+
} else {
|
|
293
|
+
this.#state = 'CLOSED';
|
|
294
|
+
this.#closedAt = Date.now();
|
|
295
|
+
}
|
|
296
|
+
if (this.#onEnd) this.#onEnd();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** @internal Handle inbound error */
|
|
300
|
+
_receiveError(code, message) {
|
|
301
|
+
if (this.#state === 'CLOSED') return;
|
|
302
|
+
this.#state = 'CLOSED';
|
|
303
|
+
this.#closedAt = Date.now();
|
|
304
|
+
this.#sendQueue.length = 0;
|
|
305
|
+
for (const r of this.#creditResolvers) {
|
|
306
|
+
r.reject(new Error(message));
|
|
307
|
+
}
|
|
308
|
+
this.#creditResolvers.length = 0;
|
|
309
|
+
if (this.#onError) this.#onError({ code, message });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Stats ────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
getStats() {
|
|
315
|
+
return {
|
|
316
|
+
bytesSent: this.#bytesSent,
|
|
317
|
+
bytesReceived: this.#bytesReceived,
|
|
318
|
+
framesSent: this.#framesSent,
|
|
319
|
+
framesReceived: this.#framesReceived,
|
|
320
|
+
duration: (this.#closedAt || Date.now()) - this.#createdAt,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Serialization ────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
toJSON() {
|
|
327
|
+
return {
|
|
328
|
+
id: this.#hexId,
|
|
329
|
+
state: this.#state,
|
|
330
|
+
method: this.#method,
|
|
331
|
+
ordered: this.#ordered,
|
|
332
|
+
encrypted: this.#encrypted,
|
|
333
|
+
metadata: this.#metadata,
|
|
334
|
+
initiator: this.#initiator,
|
|
335
|
+
sendSeq: this.#sendSeq,
|
|
336
|
+
recvSeq: this.#recvSeq,
|
|
337
|
+
sendCredits: this.#sendCredits,
|
|
338
|
+
recvCredits: this.#recvCredits,
|
|
339
|
+
bytesSent: this.#bytesSent,
|
|
340
|
+
bytesReceived: this.#bytesReceived,
|
|
341
|
+
framesSent: this.#framesSent,
|
|
342
|
+
framesReceived: this.#framesReceived,
|
|
343
|
+
createdAt: this.#createdAt,
|
|
344
|
+
closedAt: this.#closedAt,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
static fromJSON(json, multiplexer = null) {
|
|
349
|
+
// Reconstruct hex ID as Uint8Array
|
|
350
|
+
const idBytes = new Uint8Array(json.id.match(/.{2}/g).map(h => parseInt(h, 16)));
|
|
351
|
+
const stream = new MeshStream({
|
|
352
|
+
id: idBytes,
|
|
353
|
+
method: json.method,
|
|
354
|
+
ordered: json.ordered,
|
|
355
|
+
encrypted: json.encrypted,
|
|
356
|
+
metadata: json.metadata,
|
|
357
|
+
initiator: json.initiator,
|
|
358
|
+
initialCredits: 0, // set manually below
|
|
359
|
+
maxSize: STREAM_DEFAULTS.maxStreamSize,
|
|
360
|
+
createdAt: json.createdAt,
|
|
361
|
+
multiplexer,
|
|
362
|
+
});
|
|
363
|
+
// Restore internal state
|
|
364
|
+
stream.#state = json.state;
|
|
365
|
+
stream.#sendSeq = json.sendSeq;
|
|
366
|
+
stream.#recvSeq = json.recvSeq;
|
|
367
|
+
stream.#sendCredits = json.sendCredits;
|
|
368
|
+
stream.#recvCredits = json.recvCredits;
|
|
369
|
+
stream.#bytesSent = json.bytesSent;
|
|
370
|
+
stream.#bytesReceived = json.bytesReceived;
|
|
371
|
+
stream.#framesSent = json.framesSent;
|
|
372
|
+
stream.#framesReceived = json.framesReceived;
|
|
373
|
+
stream.#closedAt = json.closedAt;
|
|
374
|
+
return stream;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── StreamMultiplexer ────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Multi-stream manager. Routes inbound messages to the correct stream,
|
|
382
|
+
* enforces concurrency limits, and exposes an API for opening/closing streams.
|
|
383
|
+
*/
|
|
384
|
+
export class StreamMultiplexer {
|
|
385
|
+
/** @type {Map<string, MeshStream>} hexId → stream */
|
|
386
|
+
#streams = new Map();
|
|
387
|
+
#maxConcurrent;
|
|
388
|
+
#defaults;
|
|
389
|
+
|
|
390
|
+
// Callbacks
|
|
391
|
+
#onStream = null;
|
|
392
|
+
#onSend = null;
|
|
393
|
+
|
|
394
|
+
constructor(opts = {}) {
|
|
395
|
+
this.#maxConcurrent = opts.maxConcurrentStreams ?? STREAM_DEFAULTS.maxConcurrentStreams;
|
|
396
|
+
this.#defaults = { ...STREAM_DEFAULTS, ...opts };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Callbacks ────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Register callback for inbound streams opened by the remote side.
|
|
403
|
+
* @param {(stream: MeshStream) => void} cb
|
|
404
|
+
*/
|
|
405
|
+
onStream(cb) { this.#onStream = cb; return this; }
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Register callback for outbound messages that need to be sent over the wire.
|
|
409
|
+
* @param {(msg: object) => void} cb
|
|
410
|
+
*/
|
|
411
|
+
onSend(cb) { this.#onSend = cb; return this; }
|
|
412
|
+
|
|
413
|
+
// ── Open / Close ─────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Open a new outgoing stream.
|
|
417
|
+
* @param {string} method - Stream purpose (e.g. 'storage/upload')
|
|
418
|
+
* @param {object} [opts]
|
|
419
|
+
* @returns {MeshStream}
|
|
420
|
+
*/
|
|
421
|
+
open(method, opts = {}) {
|
|
422
|
+
const active = this.activeCount;
|
|
423
|
+
if (active >= this.#maxConcurrent) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`Concurrent stream limit reached (${active}/${this.#maxConcurrent})`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const stream = new MeshStream({
|
|
430
|
+
method,
|
|
431
|
+
ordered: opts.ordered,
|
|
432
|
+
encrypted: opts.encrypted,
|
|
433
|
+
metadata: opts.metadata,
|
|
434
|
+
initiator: true,
|
|
435
|
+
initialCredits: opts.initialCredits ?? this.#defaults.initialCredits,
|
|
436
|
+
maxSize: opts.maxSize ?? this.#defaults.maxStreamSize,
|
|
437
|
+
multiplexer: this,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
stream._open();
|
|
441
|
+
this.#streams.set(stream.hexId, stream);
|
|
442
|
+
|
|
443
|
+
// Send STREAM_OPEN to remote
|
|
444
|
+
this._emit({
|
|
445
|
+
t: MESH_TYPE.STREAM_OPEN,
|
|
446
|
+
p: {
|
|
447
|
+
streamId: stream.hexId,
|
|
448
|
+
method,
|
|
449
|
+
ordered: stream.ordered,
|
|
450
|
+
encrypted: stream.encrypted,
|
|
451
|
+
initialCredits: stream.sendCredits,
|
|
452
|
+
metadata: stream.metadata,
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
return stream;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Close a stream by ID.
|
|
461
|
+
* @param {string} streamId - hex stream ID
|
|
462
|
+
*/
|
|
463
|
+
close(streamId) {
|
|
464
|
+
const stream = this.#streams.get(streamId);
|
|
465
|
+
if (!stream) return;
|
|
466
|
+
stream.end();
|
|
467
|
+
if (stream.state === 'CLOSED') {
|
|
468
|
+
this.#streams.delete(streamId);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Close all active streams.
|
|
474
|
+
*/
|
|
475
|
+
closeAll() {
|
|
476
|
+
for (const [id, stream] of this.#streams) {
|
|
477
|
+
stream.cancel('Multiplexer closing all streams');
|
|
478
|
+
this.#streams.delete(id);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── Dispatch inbound messages ────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Route an inbound message to the correct stream.
|
|
486
|
+
* @param {object} msg - Wire message with `t` and `p` fields
|
|
487
|
+
*/
|
|
488
|
+
dispatch(msg) {
|
|
489
|
+
if (!msg || !msg.p) return;
|
|
490
|
+
const streamId = msg.p.streamId;
|
|
491
|
+
if (!streamId) return;
|
|
492
|
+
|
|
493
|
+
const hexId = typeof streamId === 'string' ? streamId : streamIdToHex(streamId);
|
|
494
|
+
|
|
495
|
+
// STREAM_OPEN: new inbound stream
|
|
496
|
+
if (msg.t === MESH_TYPE.STREAM_OPEN) {
|
|
497
|
+
if (this.#streams.has(hexId)) return; // Duplicate
|
|
498
|
+
if (this.activeCount >= this.#maxConcurrent) {
|
|
499
|
+
this._emit({
|
|
500
|
+
t: 0x15, // STREAM_ERROR
|
|
501
|
+
p: { streamId: hexId, code: 'FLOW_CONTROL', message: 'Too many concurrent streams', retryable: true },
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const stream = new MeshStream({
|
|
506
|
+
id: typeof streamId === 'string'
|
|
507
|
+
? new Uint8Array(streamId.match(/.{2}/g).map(h => parseInt(h, 16)))
|
|
508
|
+
: streamId,
|
|
509
|
+
method: msg.p.method,
|
|
510
|
+
ordered: msg.p.ordered !== false,
|
|
511
|
+
encrypted: msg.p.encrypted === true,
|
|
512
|
+
metadata: msg.p.metadata || {},
|
|
513
|
+
initiator: false,
|
|
514
|
+
initialCredits: msg.p.initialCredits ?? STREAM_DEFAULTS.initialCredits,
|
|
515
|
+
multiplexer: this,
|
|
516
|
+
});
|
|
517
|
+
stream._open();
|
|
518
|
+
this.#streams.set(hexId, stream);
|
|
519
|
+
if (this.#onStream) this.#onStream(stream);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const stream = this.#streams.get(hexId);
|
|
524
|
+
if (!stream) {
|
|
525
|
+
// Unknown stream — send error
|
|
526
|
+
this._emit({
|
|
527
|
+
t: 0x15,
|
|
528
|
+
p: { streamId: hexId, code: 'INTERNAL', message: 'Unknown stream ID', retryable: false },
|
|
529
|
+
});
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
switch (msg.t) {
|
|
534
|
+
case 0x13: // STREAM_DATA
|
|
535
|
+
stream._receiveData(msg.p.data, msg.p.seq);
|
|
536
|
+
break;
|
|
537
|
+
case 0x14: // STREAM_END
|
|
538
|
+
stream._receiveEnd();
|
|
539
|
+
if (stream.state === 'CLOSED') this.#streams.delete(hexId);
|
|
540
|
+
break;
|
|
541
|
+
case 0x15: // STREAM_ERROR
|
|
542
|
+
stream._receiveError(msg.p.code, msg.p.message);
|
|
543
|
+
this.#streams.delete(hexId);
|
|
544
|
+
break;
|
|
545
|
+
case 0x16: // STREAM_WINDOW_UPDATE
|
|
546
|
+
stream._receiveCredits(msg.p.additionalCredits);
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ── Queries ──────────────────────────────────────────────────────
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Get a stream by hex ID.
|
|
555
|
+
* @param {string} id
|
|
556
|
+
* @returns {MeshStream|undefined}
|
|
557
|
+
*/
|
|
558
|
+
getStream(id) { return this.#streams.get(id); }
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* List all streams, optionally filtered by state.
|
|
562
|
+
* @param {string} [stateFilter]
|
|
563
|
+
* @returns {MeshStream[]}
|
|
564
|
+
*/
|
|
565
|
+
listStreams(stateFilter) {
|
|
566
|
+
const all = [...this.#streams.values()];
|
|
567
|
+
return stateFilter ? all.filter(s => s.state === stateFilter) : all;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Number of non-CLOSED streams. */
|
|
571
|
+
get activeCount() {
|
|
572
|
+
let n = 0;
|
|
573
|
+
for (const s of this.#streams.values()) {
|
|
574
|
+
if (s.state !== 'CLOSED') n++;
|
|
575
|
+
}
|
|
576
|
+
return n;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Total number of tracked streams (including CLOSED). */
|
|
580
|
+
get size() { return this.#streams.size; }
|
|
581
|
+
|
|
582
|
+
// ── Internal send helpers (called by MeshStream) ─────────────────
|
|
583
|
+
|
|
584
|
+
/** @internal */ _sendData(hexId, data, seq) {
|
|
585
|
+
this._emit({ t: 0x13, p: { streamId: hexId, data, seq } });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** @internal */ _sendEnd(hexId, totalBytes) {
|
|
589
|
+
this._emit({ t: 0x14, p: { streamId: hexId, totalBytes } });
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** @internal */ _sendError(hexId, code, message) {
|
|
593
|
+
this._emit({ t: 0x15, p: { streamId: hexId, code, message, retryable: false } });
|
|
594
|
+
this.#streams.delete(hexId);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** @internal */ _sendWindowUpdate(hexId, additionalCredits) {
|
|
598
|
+
this._emit({ t: 0x16, p: { streamId: hexId, additionalCredits } });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** @internal Emit a wire message via the onSend callback. */
|
|
602
|
+
_emit(msg) {
|
|
603
|
+
if (this.#onSend) this.#onSend(msg);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ── Serialization ────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
toJSON() {
|
|
609
|
+
const streams = {};
|
|
610
|
+
for (const [id, stream] of this.#streams) {
|
|
611
|
+
streams[id] = stream.toJSON();
|
|
612
|
+
}
|
|
613
|
+
return { maxConcurrent: this.#maxConcurrent, streams };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
static fromJSON(json, opts = {}) {
|
|
617
|
+
const mux = new StreamMultiplexer({
|
|
618
|
+
maxConcurrentStreams: json.maxConcurrent,
|
|
619
|
+
...opts,
|
|
620
|
+
});
|
|
621
|
+
for (const [id, data] of Object.entries(json.streams)) {
|
|
622
|
+
const stream = MeshStream.fromJSON(data, mux);
|
|
623
|
+
mux.#streams.set(id, stream);
|
|
624
|
+
}
|
|
625
|
+
return mux;
|
|
626
|
+
}
|
|
627
|
+
}
|