@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.
@@ -0,0 +1,627 @@
1
+ /**
2
+ // STATUS: INTEGRATED — wired into ClawserPod lifecycle, proven via E2E testing
3
+ * clawser-mesh-gateway.js -- Gateway Node for BrowserMesh.
4
+ *
5
+ * Thin wrapper around relay functionality providing multi-hop routing,
6
+ * route advertisement, and gateway discovery for the mesh network.
7
+ *
8
+ * GatewayRoute represents a single route between two pods via a gateway.
9
+ * RouteTable manages a collection of routes with TTL expiration.
10
+ * GatewayNode orchestrates peer registration, route management, and relay.
11
+ * GatewayDiscovery tracks available gateways for destination selection.
12
+ *
13
+ * No browser-only imports at module level.
14
+ *
15
+ * Run tests:
16
+ * node --import ./web/test/_setup-globals.mjs --test web/test/clawser-mesh-gateway.test.mjs
17
+ */
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Wire Constants
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** Gateway announcement message type. */
24
+ const GATEWAY_ANNOUNCE = 0xA0;
25
+
26
+ /** Gateway route advertisement message type. */
27
+ const GATEWAY_ROUTE = 0xA1;
28
+
29
+ /** Gateway relay request message type. */
30
+ const GATEWAY_RELAY = 0xA2;
31
+
32
+ /** Gateway route withdrawal message type. */
33
+ const GATEWAY_WITHDRAW = 0xA3;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // GatewayRoute
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * A single route between two pods via a gateway node.
41
+ */
42
+ export class GatewayRoute {
43
+ /**
44
+ * @param {object} opts
45
+ * @param {string} opts.fromPodId - Source pod identifier
46
+ * @param {string} opts.toPodId - Destination pod identifier
47
+ * @param {string} opts.viaGateway - Gateway pod that forwarded this route
48
+ * @param {number} opts.hopCount - Number of hops along this route
49
+ * @param {number} [opts.latencyMs] - Estimated latency in milliseconds
50
+ * @param {number} [opts.createdAt] - Unix timestamp (ms) when route was created
51
+ * @param {number} [opts.ttl] - Time-to-live in milliseconds
52
+ */
53
+ constructor({
54
+ fromPodId,
55
+ toPodId,
56
+ viaGateway,
57
+ hopCount,
58
+ latencyMs = null,
59
+ createdAt = Date.now(),
60
+ ttl = 60_000,
61
+ }) {
62
+ if (!fromPodId || typeof fromPodId !== 'string') {
63
+ throw new Error('fromPodId is required and must be a non-empty string');
64
+ }
65
+ if (!toPodId || typeof toPodId !== 'string') {
66
+ throw new Error('toPodId is required and must be a non-empty string');
67
+ }
68
+ if (!viaGateway || typeof viaGateway !== 'string') {
69
+ throw new Error('viaGateway is required and must be a non-empty string');
70
+ }
71
+ if (typeof hopCount !== 'number' || hopCount < 0) {
72
+ throw new Error('hopCount must be a non-negative number');
73
+ }
74
+ this.fromPodId = fromPodId;
75
+ this.toPodId = toPodId;
76
+ this.viaGateway = viaGateway;
77
+ this.hopCount = hopCount;
78
+ this.latencyMs = latencyMs;
79
+ this.createdAt = createdAt;
80
+ this.ttl = ttl;
81
+ }
82
+
83
+ /**
84
+ * Check whether this route has expired.
85
+ *
86
+ * @param {number} [now=Date.now()] - Current timestamp in ms
87
+ * @returns {boolean}
88
+ */
89
+ isExpired(now = Date.now()) {
90
+ return now >= this.createdAt + this.ttl;
91
+ }
92
+
93
+ /**
94
+ * Serialize to a JSON-safe object.
95
+ * @returns {object}
96
+ */
97
+ toJSON() {
98
+ return {
99
+ fromPodId: this.fromPodId,
100
+ toPodId: this.toPodId,
101
+ viaGateway: this.viaGateway,
102
+ hopCount: this.hopCount,
103
+ latencyMs: this.latencyMs,
104
+ createdAt: this.createdAt,
105
+ ttl: this.ttl,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Re-hydrate from a plain object.
111
+ * @param {object} data
112
+ * @returns {GatewayRoute}
113
+ */
114
+ static fromJSON(data) {
115
+ return new GatewayRoute(data);
116
+ }
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // RouteTable
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Collection of gateway routes with TTL-based expiration.
125
+ */
126
+ export class RouteTable {
127
+ /** @type {Map<string, GatewayRoute>} key -> route */
128
+ #routes = new Map();
129
+
130
+ /** @type {number} */
131
+ #maxRoutes;
132
+
133
+ /** @type {number} */
134
+ #ttlMs;
135
+
136
+ /**
137
+ * @param {object} [opts]
138
+ * @param {number} [opts.maxRoutes=1000] - Maximum number of routes to store
139
+ * @param {number} [opts.ttlMs=60000] - Default TTL for routes in ms
140
+ */
141
+ constructor(opts = {}) {
142
+ this.#maxRoutes = opts.maxRoutes ?? 1000;
143
+ this.#ttlMs = opts.ttlMs ?? 60_000;
144
+ }
145
+
146
+ /**
147
+ * Generate a composite key for a route.
148
+ * @param {string} fromPodId
149
+ * @param {string} toPodId
150
+ * @returns {string}
151
+ */
152
+ static #key(fromPodId, toPodId) {
153
+ return `${fromPodId}:${toPodId}`;
154
+ }
155
+
156
+ /**
157
+ * Add or replace a route.
158
+ * If the table is full, the oldest route is evicted.
159
+ *
160
+ * @param {GatewayRoute} route
161
+ */
162
+ addRoute(route) {
163
+ const key = RouteTable.#key(route.fromPodId, route.toPodId);
164
+ if (!this.#routes.has(key) && this.#routes.size >= this.#maxRoutes) {
165
+ // Evict the oldest route
166
+ const oldest = this.#routes.keys().next().value;
167
+ this.#routes.delete(oldest);
168
+ }
169
+ this.#routes.set(key, route);
170
+ }
171
+
172
+ /**
173
+ * Remove a specific route.
174
+ *
175
+ * @param {string} fromPodId
176
+ * @param {string} toPodId
177
+ * @returns {boolean} true if the route existed
178
+ */
179
+ removeRoute(fromPodId, toPodId) {
180
+ return this.#routes.delete(RouteTable.#key(fromPodId, toPodId));
181
+ }
182
+
183
+ /**
184
+ * Find a specific route.
185
+ *
186
+ * @param {string} fromPodId
187
+ * @param {string} toPodId
188
+ * @returns {GatewayRoute|null}
189
+ */
190
+ findRoute(fromPodId, toPodId) {
191
+ return this.#routes.get(RouteTable.#key(fromPodId, toPodId)) ?? null;
192
+ }
193
+
194
+ /**
195
+ * Find all routes to a destination, sorted by hopCount ascending.
196
+ *
197
+ * @param {string} toPodId
198
+ * @returns {GatewayRoute[]}
199
+ */
200
+ findRoutes(toPodId) {
201
+ const matches = [];
202
+ for (const route of this.#routes.values()) {
203
+ if (route.toPodId === toPodId) {
204
+ matches.push(route);
205
+ }
206
+ }
207
+ matches.sort((a, b) => a.hopCount - b.hopCount);
208
+ return matches;
209
+ }
210
+
211
+ /**
212
+ * Remove all expired routes.
213
+ *
214
+ * @returns {number} number of routes pruned
215
+ */
216
+ pruneExpired() {
217
+ const now = Date.now();
218
+ let count = 0;
219
+ for (const [key, route] of this.#routes) {
220
+ if (route.isExpired(now)) {
221
+ this.#routes.delete(key);
222
+ count++;
223
+ }
224
+ }
225
+ return count;
226
+ }
227
+
228
+ /** @returns {number} current number of routes */
229
+ get size() {
230
+ return this.#routes.size;
231
+ }
232
+
233
+ /** @returns {number} configured default TTL */
234
+ get defaultTtl() {
235
+ return this.#ttlMs;
236
+ }
237
+
238
+ /**
239
+ * List all routes.
240
+ * @returns {GatewayRoute[]}
241
+ */
242
+ listAll() {
243
+ return [...this.#routes.values()];
244
+ }
245
+
246
+ /**
247
+ * Serialize to a JSON-safe object.
248
+ * @returns {object}
249
+ */
250
+ toJSON() {
251
+ return {
252
+ maxRoutes: this.#maxRoutes,
253
+ ttlMs: this.#ttlMs,
254
+ routes: [...this.#routes.values()].map(r => r.toJSON()),
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Re-hydrate from serialized data.
260
+ * @param {object} data
261
+ * @returns {RouteTable}
262
+ */
263
+ static fromJSON(data) {
264
+ const table = new RouteTable({
265
+ maxRoutes: data.maxRoutes,
266
+ ttlMs: data.ttlMs,
267
+ });
268
+ for (const r of data.routes || []) {
269
+ table.addRoute(GatewayRoute.fromJSON(r));
270
+ }
271
+ return table;
272
+ }
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // GatewayNode
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /**
280
+ * Orchestrates peer registration, route management, and payload relay
281
+ * for a single gateway node in the mesh.
282
+ */
283
+ export class GatewayNode {
284
+ /** @type {string} */
285
+ #localPodId;
286
+
287
+ /** @type {Set<string>} connected peer pod IDs */
288
+ #peers = new Set();
289
+
290
+ /** @type {RouteTable} */
291
+ #routeTable;
292
+
293
+ /** @type {number} */
294
+ #maxConnections;
295
+
296
+ /** @type {number} */
297
+ #maxHops;
298
+
299
+ /** @type {boolean} */
300
+ #allowRelay;
301
+
302
+ /** @type {number} total relay operations performed */
303
+ #relayCount = 0;
304
+
305
+ /**
306
+ * @param {string} localPodId - This gateway's pod identifier
307
+ * @param {object} [opts]
308
+ * @param {number} [opts.maxConnections=64] - Maximum peer connections
309
+ * @param {number} [opts.maxHops=8] - Maximum hop count for routes
310
+ * @param {boolean} [opts.allowRelay=true] - Whether relay is enabled
311
+ */
312
+ constructor(localPodId, opts = {}) {
313
+ if (!localPodId || typeof localPodId !== 'string') {
314
+ throw new Error('localPodId is required and must be a non-empty string');
315
+ }
316
+ this.#localPodId = localPodId;
317
+ this.#maxConnections = opts.maxConnections ?? 64;
318
+ this.#maxHops = opts.maxHops ?? 8;
319
+ this.#allowRelay = opts.allowRelay !== undefined ? opts.allowRelay : true;
320
+ this.#routeTable = new RouteTable();
321
+ }
322
+
323
+ // -- Accessors ------------------------------------------------------------
324
+
325
+ /** This gateway's pod identifier. */
326
+ get localPodId() {
327
+ return this.#localPodId;
328
+ }
329
+
330
+ /** Whether relay is enabled. */
331
+ get isRelayEnabled() {
332
+ return this.#allowRelay;
333
+ }
334
+
335
+ /** Set of connected peer pod IDs. */
336
+ get connectedPeers() {
337
+ return new Set(this.#peers);
338
+ }
339
+
340
+ /** The underlying route table. */
341
+ get routeTable() {
342
+ return this.#routeTable;
343
+ }
344
+
345
+ // -- Peer Management ------------------------------------------------------
346
+
347
+ /**
348
+ * Register a directly connected peer.
349
+ *
350
+ * @param {string} podId
351
+ */
352
+ registerPeer(podId) {
353
+ if (!podId || typeof podId !== 'string') {
354
+ throw new Error('podId is required and must be a non-empty string');
355
+ }
356
+ if (this.#peers.size >= this.#maxConnections && !this.#peers.has(podId)) {
357
+ throw new Error('Maximum connections reached');
358
+ }
359
+ this.#peers.add(podId);
360
+ }
361
+
362
+ /**
363
+ * Unregister a peer connection.
364
+ *
365
+ * @param {string} podId
366
+ * @returns {boolean} true if the peer existed
367
+ */
368
+ unregisterPeer(podId) {
369
+ return this.#peers.delete(podId);
370
+ }
371
+
372
+ // -- Routing --------------------------------------------------------------
373
+
374
+ /**
375
+ * Check if a route exists between two pods.
376
+ * A route exists if both pods are directly connected peers, or if
377
+ * an explicit route is registered in the route table.
378
+ *
379
+ * @param {string} fromPodId
380
+ * @param {string} toPodId
381
+ * @returns {boolean}
382
+ */
383
+ canRoute(fromPodId, toPodId) {
384
+ // Direct connection: both peers are registered
385
+ if (this.#peers.has(fromPodId) && this.#peers.has(toPodId)) {
386
+ return true;
387
+ }
388
+ // Explicit route in the table
389
+ return this.#routeTable.findRoute(fromPodId, toPodId) !== null;
390
+ }
391
+
392
+ /**
393
+ * Find the best route (lowest hop count) between two pods.
394
+ * Returns null if no route exists.
395
+ *
396
+ * @param {string} fromPodId
397
+ * @param {string} toPodId
398
+ * @returns {GatewayRoute|null}
399
+ */
400
+ findBestRoute(fromPodId, toPodId) {
401
+ // Check for direct route in table first
402
+ const direct = this.#routeTable.findRoute(fromPodId, toPodId);
403
+ if (direct) return direct;
404
+
405
+ // If both are directly connected peers, synthesize a 1-hop route
406
+ if (this.#peers.has(fromPodId) && this.#peers.has(toPodId)) {
407
+ return new GatewayRoute({
408
+ fromPodId,
409
+ toPodId,
410
+ viaGateway: this.#localPodId,
411
+ hopCount: 1,
412
+ });
413
+ }
414
+
415
+ return null;
416
+ }
417
+
418
+ /**
419
+ * Advertise a route through this gateway.
420
+ * Rejects routes exceeding maxHops.
421
+ *
422
+ * @param {string} fromPodId
423
+ * @param {string} toPodId
424
+ * @param {number} hopCount
425
+ * @returns {GatewayRoute}
426
+ */
427
+ advertiseRoute(fromPodId, toPodId, hopCount) {
428
+ if (hopCount > this.#maxHops) {
429
+ throw new Error(`hopCount ${hopCount} exceeds maxHops ${this.#maxHops}`);
430
+ }
431
+ const route = new GatewayRoute({
432
+ fromPodId,
433
+ toPodId,
434
+ viaGateway: this.#localPodId,
435
+ hopCount,
436
+ ttl: this.#routeTable.defaultTtl,
437
+ });
438
+ this.#routeTable.addRoute(route);
439
+ return route;
440
+ }
441
+
442
+ /**
443
+ * Revoke (remove) an advertised route.
444
+ *
445
+ * @param {string} fromPodId
446
+ * @param {string} toPodId
447
+ * @returns {boolean} true if a route was removed
448
+ */
449
+ revokeRoute(fromPodId, toPodId) {
450
+ return this.#routeTable.removeRoute(fromPodId, toPodId);
451
+ }
452
+
453
+ /**
454
+ * Relay a payload from one pod to another.
455
+ * Returns a result object indicating success or failure.
456
+ *
457
+ * @param {string} fromPodId
458
+ * @param {string} toPodId
459
+ * @param {*} payload
460
+ * @returns {{ relayed: boolean, route?: GatewayRoute, error?: string }}
461
+ */
462
+ relay(fromPodId, toPodId, payload) {
463
+ if (!this.#allowRelay) {
464
+ return { relayed: false, error: 'Relay is disabled on this gateway' };
465
+ }
466
+ const route = this.findBestRoute(fromPodId, toPodId);
467
+ if (!route) {
468
+ return { relayed: false, error: `No route from ${fromPodId} to ${toPodId}` };
469
+ }
470
+ this.#relayCount++;
471
+ return { relayed: true, route };
472
+ }
473
+
474
+ // -- Statistics ------------------------------------------------------------
475
+
476
+ /**
477
+ * Get aggregate gateway statistics.
478
+ * @returns {{ routeCount: number, peerCount: number, relayCount: number }}
479
+ */
480
+ get stats() {
481
+ return {
482
+ routeCount: this.#routeTable.size,
483
+ peerCount: this.#peers.size,
484
+ relayCount: this.#relayCount,
485
+ };
486
+ }
487
+
488
+ // -- Serialization --------------------------------------------------------
489
+
490
+ /**
491
+ * Serialize to a JSON-safe object.
492
+ * @returns {object}
493
+ */
494
+ toJSON() {
495
+ return {
496
+ localPodId: this.#localPodId,
497
+ maxConnections: this.#maxConnections,
498
+ maxHops: this.#maxHops,
499
+ allowRelay: this.#allowRelay,
500
+ peers: [...this.#peers],
501
+ routeTable: this.#routeTable.toJSON(),
502
+ relayCount: this.#relayCount,
503
+ };
504
+ }
505
+
506
+ /**
507
+ * Re-hydrate from serialized data.
508
+ * @param {object} data
509
+ * @returns {GatewayNode}
510
+ */
511
+ static fromJSON(data) {
512
+ const node = new GatewayNode(data.localPodId, {
513
+ maxConnections: data.maxConnections,
514
+ maxHops: data.maxHops,
515
+ allowRelay: data.allowRelay,
516
+ });
517
+ for (const peerId of data.peers || []) {
518
+ node.registerPeer(peerId);
519
+ }
520
+ node.#routeTable = RouteTable.fromJSON(data.routeTable);
521
+ node.#relayCount = data.relayCount || 0;
522
+ return node;
523
+ }
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // GatewayDiscovery
528
+ // ---------------------------------------------------------------------------
529
+
530
+ /**
531
+ * Tracks available gateways in the mesh for destination selection.
532
+ */
533
+ export class GatewayDiscovery {
534
+ /** @type {string} */
535
+ #localPodId;
536
+
537
+ /** @type {Map<string, { podId: string, capabilities: string[], addedAt: number }>} */
538
+ #gateways = new Map();
539
+
540
+ /**
541
+ * @param {string} localPodId - This node's pod identifier
542
+ */
543
+ constructor(localPodId) {
544
+ if (!localPodId || typeof localPodId !== 'string') {
545
+ throw new Error('localPodId is required and must be a non-empty string');
546
+ }
547
+ this.#localPodId = localPodId;
548
+ }
549
+
550
+ /**
551
+ * Register a gateway node.
552
+ *
553
+ * @param {string} podId
554
+ * @param {string[]} [capabilities=[]]
555
+ */
556
+ addGateway(podId, capabilities = []) {
557
+ if (!podId || typeof podId !== 'string') {
558
+ throw new Error('podId is required and must be a non-empty string');
559
+ }
560
+ this.#gateways.set(podId, {
561
+ podId,
562
+ capabilities: [...capabilities],
563
+ addedAt: Date.now(),
564
+ });
565
+ }
566
+
567
+ /**
568
+ * Remove a gateway.
569
+ *
570
+ * @param {string} podId
571
+ * @returns {boolean} true if the gateway existed
572
+ */
573
+ removeGateway(podId) {
574
+ return this.#gateways.delete(podId);
575
+ }
576
+
577
+ /**
578
+ * List all known gateways.
579
+ *
580
+ * @returns {Array<{ podId: string, capabilities: string[], addedAt: number }>}
581
+ */
582
+ listGateways() {
583
+ return [...this.#gateways.values()].map(g => ({
584
+ podId: g.podId,
585
+ capabilities: [...g.capabilities],
586
+ addedAt: g.addedAt,
587
+ }));
588
+ }
589
+
590
+ /**
591
+ * Select the best gateway for reaching a destination.
592
+ * Returns the first gateway that isn't the local node and isn't the
593
+ * destination itself. Returns null if no suitable gateway exists.
594
+ *
595
+ * @param {string} toPodId
596
+ * @param {object} [opts]
597
+ * @param {string} [opts.requiredCapability] - Only consider gateways with this capability
598
+ * @returns {string|null} pod ID of the selected gateway, or null
599
+ */
600
+ selectGateway(toPodId, opts = {}) {
601
+ for (const gw of this.#gateways.values()) {
602
+ if (gw.podId === this.#localPodId) continue;
603
+ if (gw.podId === toPodId) continue;
604
+ if (opts.requiredCapability && !gw.capabilities.includes(opts.requiredCapability)) {
605
+ continue;
606
+ }
607
+ return gw.podId;
608
+ }
609
+ return null;
610
+ }
611
+
612
+ /** @returns {number} number of known gateways */
613
+ get size() {
614
+ return this.#gateways.size;
615
+ }
616
+ }
617
+
618
+ // ---------------------------------------------------------------------------
619
+ // Exports
620
+ // ---------------------------------------------------------------------------
621
+
622
+ export {
623
+ GATEWAY_ANNOUNCE,
624
+ GATEWAY_ROUTE,
625
+ GATEWAY_RELAY,
626
+ GATEWAY_WITHDRAW,
627
+ };
package/src/index.mjs ADDED
@@ -0,0 +1,12 @@
1
+ // browsermesh-transport — Transport layer
2
+ export * from './transport.mjs';
3
+ export * from './websocket.mjs';
4
+ export * from './webrtc.mjs';
5
+ export * from './webtransport.mjs';
6
+ export * from './relay.mjs';
7
+ export * from './gateway.mjs';
8
+ export * from './streams.mjs';
9
+ export * from './cross-origin.mjs';
10
+ export * from './wsh-bridge.mjs';
11
+ export * from './wisp.mjs';
12
+ export * from './channel-relay.mjs';