wire-mesh-core 0.0.0 → 1.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.
Files changed (72) hide show
  1. package/README.md +32 -0
  2. package/dist/adapters/frame-codec.cjs +37 -0
  3. package/dist/adapters/frame-codec.d.cts +12 -0
  4. package/dist/adapters/frame-codec.d.mts +12 -0
  5. package/dist/adapters/frame-codec.mjs +33 -0
  6. package/dist/adapters/memory-storage.cjs +14 -0
  7. package/dist/adapters/memory-storage.d.cts +5 -0
  8. package/dist/adapters/memory-storage.d.mts +5 -0
  9. package/dist/adapters/memory-storage.mjs +13 -0
  10. package/dist/adapters/node-identity.cjs +55 -0
  11. package/dist/adapters/node-identity.d.cts +11 -0
  12. package/dist/adapters/node-identity.d.mts +11 -0
  13. package/dist/adapters/node-identity.mjs +52 -0
  14. package/dist/adapters/system-clock.cjs +8 -0
  15. package/dist/adapters/system-clock.d.cts +5 -0
  16. package/dist/adapters/system-clock.d.mts +5 -0
  17. package/dist/adapters/system-clock.mjs +7 -0
  18. package/dist/adapters/tcp-transport.cjs +154 -0
  19. package/dist/adapters/tcp-transport.d.cts +5 -0
  20. package/dist/adapters/tcp-transport.d.mts +5 -0
  21. package/dist/adapters/tcp-transport.mjs +153 -0
  22. package/dist/adapters/tls-transport.cjs +178 -0
  23. package/dist/adapters/tls-transport.d.cts +9 -0
  24. package/dist/adapters/tls-transport.d.mts +9 -0
  25. package/dist/adapters/tls-transport.mjs +177 -0
  26. package/dist/domain/device-id.cjs +27 -0
  27. package/dist/domain/device-id.d.cts +9 -0
  28. package/dist/domain/device-id.d.mts +9 -0
  29. package/dist/domain/device-id.mjs +24 -0
  30. package/dist/domain/handshake.cjs +23 -0
  31. package/dist/domain/handshake.d.cts +16 -0
  32. package/dist/domain/handshake.d.mts +16 -0
  33. package/dist/domain/handshake.mjs +21 -0
  34. package/dist/domain/mesh-session.cjs +439 -0
  35. package/dist/domain/mesh-session.d.cts +101 -0
  36. package/dist/domain/mesh-session.d.mts +101 -0
  37. package/dist/domain/mesh-session.mjs +436 -0
  38. package/dist/domain/relay-hub.cjs +99 -0
  39. package/dist/domain/relay-hub.d.cts +10 -0
  40. package/dist/domain/relay-hub.d.mts +10 -0
  41. package/dist/domain/relay-hub.mjs +98 -0
  42. package/dist/domain/tokens.cjs +300 -0
  43. package/dist/domain/tokens.d.cts +86 -0
  44. package/dist/domain/tokens.d.mts +86 -0
  45. package/dist/domain/tokens.mjs +296 -0
  46. package/dist/generated/protocol.cjs +466 -0
  47. package/dist/generated/protocol.d.cts +2 -0
  48. package/dist/generated/protocol.d.mts +2 -0
  49. package/dist/generated/protocol.mjs +382 -0
  50. package/dist/generated/runtime.cjs +8 -0
  51. package/dist/generated/runtime.d.cts +2 -0
  52. package/dist/generated/runtime.d.mts +2 -0
  53. package/dist/generated/runtime.mjs +2 -0
  54. package/dist/ports/clock.cjs +0 -0
  55. package/dist/ports/clock.d.cts +6 -0
  56. package/dist/ports/clock.d.mts +6 -0
  57. package/dist/ports/clock.mjs +1 -0
  58. package/dist/ports/identity.cjs +0 -0
  59. package/dist/ports/identity.d.cts +16 -0
  60. package/dist/ports/identity.d.mts +16 -0
  61. package/dist/ports/identity.mjs +1 -0
  62. package/dist/ports/storage.cjs +0 -0
  63. package/dist/ports/storage.d.cts +9 -0
  64. package/dist/ports/storage.d.mts +9 -0
  65. package/dist/ports/storage.mjs +1 -0
  66. package/dist/ports/transport.cjs +0 -0
  67. package/dist/ports/transport.d.cts +29 -0
  68. package/dist/ports/transport.d.mts +29 -0
  69. package/dist/ports/transport.mjs +1 -0
  70. package/dist/protocol-B26-5VX7.d.cts +1112 -0
  71. package/dist/protocol-B26-5VX7.d.mts +1112 -0
  72. package/package.json +126 -2
@@ -0,0 +1,436 @@
1
+ import { deviceIdToHex } from "./device-id.mjs";
2
+ import { negotiate } from "./handshake.mjs";
3
+ import { messageFromFrame, tryDecodeFrame } from "../adapters/frame-codec.mjs";
4
+ //#region src/domain/mesh-session.ts
5
+ const MS_PER_SECOND = 1e3;
6
+ /** How long to wait for the node's handshake before calling it unanswered. A relay-only node never sends one; that is a state to display, not an error. */
7
+ const HANDSHAKE_TIMEOUT_MS = 3e3;
8
+ function localHandshake(domains) {
9
+ return {
10
+ type: "handshake",
11
+ version: 1,
12
+ domains: [...domains]
13
+ };
14
+ }
15
+ /**
16
+ * Builds the connection-agnostic session state machine and its public MeshSession surface. dial is null for a session that can never (re)connect on its own -- acceptMeshSession's case, where the one connection it will ever have already exists by construction and reconnect therefore cannot apply (only the remote redialing, and being accepted again, produces a fresh connection). createMeshSession supplies dial as transport.connect so its own connect()/reconnect behaviour is unchanged from before this was factored out.
17
+ */
18
+ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert) {
19
+ let connection = null;
20
+ let state = { status: "idle" };
21
+ let handshake = { status: "pending" };
22
+ const directory = /* @__PURE__ */ new Map();
23
+ const frameLog = [];
24
+ let feedCancelled = false;
25
+ const eventWaiters = [];
26
+ const eventBacklog = [];
27
+ let handshakeTimer = null;
28
+ let reconnectTimer = null;
29
+ let attempt = 0;
30
+ let currentToken = null;
31
+ let nextRequestId = 0;
32
+ let relayPeerDevice = null;
33
+ const pendingManageRequests = /* @__PURE__ */ new Map();
34
+ const incomingWaiters = [];
35
+ const incomingBacklog = [];
36
+ const revocationWaiters = [];
37
+ const revocationBacklog = [];
38
+ function snapshot() {
39
+ return {
40
+ state,
41
+ directory: [...directory.values()],
42
+ frameLog: [...frameLog]
43
+ };
44
+ }
45
+ function emit() {
46
+ const event = snapshot();
47
+ const waiter = eventWaiters.shift();
48
+ if (waiter) waiter(event);
49
+ else eventBacklog.push(event);
50
+ }
51
+ function emitIncomingManageRequest(request) {
52
+ const waiter = incomingWaiters.shift();
53
+ if (waiter) waiter(request);
54
+ else incomingBacklog.push(request);
55
+ }
56
+ function emitRevocationEntry(entry) {
57
+ const waiter = revocationWaiters.shift();
58
+ if (waiter) waiter(entry);
59
+ else revocationBacklog.push(entry);
60
+ }
61
+ function rejectPendingManageRequests(reason) {
62
+ for (const pending of pendingManageRequests.values()) pending.reject(new Error(reason));
63
+ pendingManageRequests.clear();
64
+ }
65
+ function buildManageRequest(command, scope, tokenOverride) {
66
+ const requestId = nextRequestId;
67
+ nextRequestId += 1;
68
+ const token = tokenOverride ?? currentToken;
69
+ return {
70
+ type: "manage-request",
71
+ "request-id": requestId,
72
+ command,
73
+ scope,
74
+ ...token !== null ? { token } : {}
75
+ };
76
+ }
77
+ /** Sends a frame, wrapping it as relay-data first when viaRelay is set -- the single choke point every outbound manage-request/manage-response passes through, so a consumer of sendManageRequest/respond never needs its own relay-wrapping logic. */
78
+ async function transmit(frame, viaRelay) {
79
+ if (connection === null) throw new Error("not connected");
80
+ if (viaRelay) {
81
+ const relayFrame = {
82
+ type: "relay-data",
83
+ payload: messageFromFrame(frame)
84
+ };
85
+ await connection.send(relayFrame);
86
+ return;
87
+ }
88
+ await connection.send(frame);
89
+ }
90
+ function applyManageResponse(frame) {
91
+ const requestId = frame["request-id"];
92
+ const pending = pendingManageRequests.get(requestId);
93
+ if (pending !== void 0) {
94
+ pendingManageRequests.delete(requestId);
95
+ pending.resolve(frame.outcome);
96
+ }
97
+ }
98
+ function applyManageRequest(frame, viaRelay) {
99
+ const requestId = frame["request-id"];
100
+ const fromDevice = viaRelay && relayPeerDevice !== null ? relayPeerDevice : void 0;
101
+ emitIncomingManageRequest({
102
+ requestId,
103
+ command: frame.command,
104
+ scope: frame.scope,
105
+ ...frame.token !== void 0 ? { token: frame.token } : {},
106
+ ...fromDevice !== void 0 ? { fromDevice } : {},
107
+ respond: async (outcome) => {
108
+ const response = {
109
+ type: "manage-response",
110
+ "request-id": requestId,
111
+ outcome
112
+ };
113
+ frameLog.push({
114
+ direction: "sent",
115
+ frame: response
116
+ });
117
+ await transmit(response, viaRelay);
118
+ emit();
119
+ }
120
+ });
121
+ }
122
+ function applyFrame(frame) {
123
+ if (frame.type === "relay-data") {
124
+ const inner = tryDecodeFrame(frame.payload);
125
+ if (inner !== null && (inner.type === "manage-request" || inner.type === "manage-response")) {
126
+ frameLog.push({
127
+ direction: "received",
128
+ frame: inner
129
+ });
130
+ if (inner.type === "manage-response") applyManageResponse(inner);
131
+ else applyManageRequest(inner, true);
132
+ return;
133
+ }
134
+ frameLog.push({
135
+ direction: "received",
136
+ frame
137
+ });
138
+ return;
139
+ }
140
+ frameLog.push({
141
+ direction: "received",
142
+ frame
143
+ });
144
+ if (frame.type === "handshake") applyRemoteHandshake(frame);
145
+ else if (frame.type === "gossip") for (const advert of frame.peers) {
146
+ directory.set(deviceIdToHex(advert.device), {
147
+ device: advert.device,
148
+ advert
149
+ });
150
+ onPeerAdvert?.(advert);
151
+ }
152
+ else if (frame.type === "relay-inbound") relayPeerDevice = frame["source-device"];
153
+ else if (frame.type === "manage-response") applyManageResponse(frame);
154
+ else if (frame.type === "manage-request") applyManageRequest(frame, false);
155
+ else if (frame.type === "revocation-announce") for (const entry of frame.entries) emitRevocationEntry(entry);
156
+ }
157
+ /** Establishes a relay-connect pairing to targetDevice if this session isn't already paired with it -- a no-op when it already is, whether that pairing was established by this session's own prior relay-connect (initiator role) or learned from an incoming relay-inbound (target role, replying back to whoever dialed it). relay-connect has no ack frame: the initiator proceeds to relay-data right after sending it. */
158
+ async function ensureRelayPairing(targetDevice) {
159
+ if (connection === null) throw new Error("not connected");
160
+ if (relayPeerDevice !== null && deviceIdToHex(relayPeerDevice) === deviceIdToHex(targetDevice)) return;
161
+ const relayConnect = {
162
+ type: "relay-connect",
163
+ "target-device": targetDevice
164
+ };
165
+ frameLog.push({
166
+ direction: "sent",
167
+ frame: relayConnect
168
+ });
169
+ await connection.send(relayConnect);
170
+ relayPeerDevice = targetDevice;
171
+ emit();
172
+ }
173
+ let localHandshakeSent = localHandshake([]);
174
+ function applyRemoteHandshake(remote) {
175
+ if (handshake.status !== "pending") return;
176
+ if (handshakeTimer !== null) {
177
+ clearTimeout(handshakeTimer);
178
+ handshakeTimer = null;
179
+ }
180
+ const result = negotiate(localHandshakeSent, remote);
181
+ handshake = result.ok ? {
182
+ status: "negotiated",
183
+ version: result.version,
184
+ sharedDomains: result.sharedDomains
185
+ } : {
186
+ status: "rejected",
187
+ reason: "no shared domains or version"
188
+ };
189
+ if (state.status === "connected") state = {
190
+ ...state,
191
+ handshake
192
+ };
193
+ }
194
+ function handleDisconnect(reason, address, localDomains) {
195
+ if (feedCancelled) return;
196
+ rejectPendingManageRequests("disconnected before a response arrived");
197
+ if (reconnect !== null && attempt < reconnect.maxAttempts) {
198
+ attempt += 1;
199
+ const currentAttempt = attempt;
200
+ state = {
201
+ status: "reconnecting",
202
+ address,
203
+ attempt: currentAttempt,
204
+ reason
205
+ };
206
+ emit();
207
+ reconnectTimer = setTimeout(() => {
208
+ reconnectTimer = null;
209
+ doConnect(address, localDomains).catch((error) => {
210
+ handleDisconnect(error instanceof Error ? error.message : String(error), address, localDomains);
211
+ });
212
+ }, reconnect.delayMs(currentAttempt));
213
+ return;
214
+ }
215
+ state = {
216
+ status: "closed",
217
+ address,
218
+ reason
219
+ };
220
+ emit();
221
+ }
222
+ async function consume(link, address, localDomains) {
223
+ for await (const frame of link.receive()) {
224
+ if (feedCancelled) return;
225
+ applyFrame(frame);
226
+ emit();
227
+ }
228
+ if (state.status === "connected") handleDisconnect("node closed the connection", address, localDomains);
229
+ }
230
+ /** Everything a connection needs once it exists, regardless of whether it was dialled (createMeshSession's own doConnect, below) or handed over already established (acceptMeshSession): send this side's handshake and self-advert, arm the handshake timeout, and start consuming frames. The two entry points differ only in how link itself came to exist and what address means for it -- a real dial target for one, a caller-chosen label for the other, since the Connection/Transport ports expose no remote-address concept of their own for an accepted connection. */
231
+ async function wireUpConnection(link, address, localDomains) {
232
+ connection = link;
233
+ localHandshakeSent = localHandshake(localDomains);
234
+ handshake = { status: "pending" };
235
+ state = {
236
+ status: "connected",
237
+ address,
238
+ handshake
239
+ };
240
+ frameLog.push({
241
+ direction: "sent",
242
+ frame: localHandshakeSent
243
+ });
244
+ await connection.send(localHandshakeSent);
245
+ emit();
246
+ const selfAdvert = {
247
+ type: "gossip",
248
+ peers: [{
249
+ device: identity.deviceId,
250
+ addresses: [],
251
+ "snapshot-seconds": Math.floor(clock.now() / MS_PER_SECOND)
252
+ }]
253
+ };
254
+ frameLog.push({
255
+ direction: "sent",
256
+ frame: selfAdvert
257
+ });
258
+ await connection.send(selfAdvert);
259
+ emit();
260
+ handshakeTimer = setTimeout(() => {
261
+ handshakeTimer = null;
262
+ if (handshake.status === "pending") {
263
+ handshake = { status: "unanswered" };
264
+ if (state.status === "connected") state = {
265
+ ...state,
266
+ handshake
267
+ };
268
+ emit();
269
+ }
270
+ }, HANDSHAKE_TIMEOUT_MS);
271
+ consume(connection, address, localDomains).catch((error) => {
272
+ if (state.status === "connected") handleDisconnect(error instanceof Error ? error.message : String(error), address, localDomains);
273
+ });
274
+ }
275
+ async function doConnect(address, localDomains) {
276
+ if (dial === null) throw new Error("this session is already connected; there is nothing to dial");
277
+ if (handshakeTimer !== null) {
278
+ clearTimeout(handshakeTimer);
279
+ handshakeTimer = null;
280
+ }
281
+ state = {
282
+ status: "connecting",
283
+ address
284
+ };
285
+ emit();
286
+ const link = await dial(address);
287
+ if (feedCancelled) {
288
+ await link.close();
289
+ return;
290
+ }
291
+ await wireUpConnection(link, address, localDomains);
292
+ }
293
+ return {
294
+ wireUpConnection,
295
+ session: {
296
+ events: { [Symbol.asyncIterator]() {
297
+ return { next: async () => new Promise((resolve) => {
298
+ const backlogEvent = eventBacklog.shift();
299
+ if (backlogEvent) resolve({
300
+ value: backlogEvent,
301
+ done: false
302
+ });
303
+ else eventWaiters.push((event) => {
304
+ resolve({
305
+ value: event,
306
+ done: false
307
+ });
308
+ });
309
+ }) };
310
+ } },
311
+ incomingManageRequests: { [Symbol.asyncIterator]() {
312
+ return { next: async () => new Promise((resolve) => {
313
+ const backlogRequest = incomingBacklog.shift();
314
+ if (backlogRequest) resolve({
315
+ value: backlogRequest,
316
+ done: false
317
+ });
318
+ else incomingWaiters.push((request) => {
319
+ resolve({
320
+ value: request,
321
+ done: false
322
+ });
323
+ });
324
+ }) };
325
+ } },
326
+ revocationAnnouncements: { [Symbol.asyncIterator]() {
327
+ return { next: async () => new Promise((resolve) => {
328
+ const backlogEntry = revocationBacklog.shift();
329
+ if (backlogEntry) resolve({
330
+ value: backlogEntry,
331
+ done: false
332
+ });
333
+ else revocationWaiters.push((entry) => {
334
+ resolve({
335
+ value: entry,
336
+ done: false
337
+ });
338
+ });
339
+ }) };
340
+ } },
341
+ async connect(address, localDomains) {
342
+ if (connection !== null) throw new Error("a session connects once; create a new one to reconnect");
343
+ attempt = 0;
344
+ await doConnect(address, localDomains);
345
+ },
346
+ async sendPing() {
347
+ if (connection === null || state.status !== "connected") throw new Error("not connected");
348
+ const ping = { type: "ping" };
349
+ frameLog.push({
350
+ direction: "sent",
351
+ frame: ping
352
+ });
353
+ await connection.send(ping);
354
+ emit();
355
+ },
356
+ setToken(token) {
357
+ currentToken = token;
358
+ },
359
+ async sendManageRequest(command, scope, targetDevice, token) {
360
+ if (connection === null || state.status !== "connected") throw new Error("not connected");
361
+ if (targetDevice !== void 0) await ensureRelayPairing(targetDevice);
362
+ const frame = buildManageRequest(command, scope, token);
363
+ const outcome = new Promise((resolve, reject) => {
364
+ pendingManageRequests.set(frame["request-id"], {
365
+ resolve,
366
+ reject
367
+ });
368
+ });
369
+ frameLog.push({
370
+ direction: "sent",
371
+ frame
372
+ });
373
+ await transmit(frame, targetDevice !== void 0);
374
+ emit();
375
+ return outcome;
376
+ },
377
+ async sendRevocationAnnounce(entries) {
378
+ if (connection === null || state.status !== "connected") throw new Error("not connected");
379
+ const frame = {
380
+ type: "revocation-announce",
381
+ entries: [...entries]
382
+ };
383
+ frameLog.push({
384
+ direction: "sent",
385
+ frame
386
+ });
387
+ await transmit(frame, false);
388
+ emit();
389
+ },
390
+ async close() {
391
+ feedCancelled = true;
392
+ if (handshakeTimer !== null) {
393
+ clearTimeout(handshakeTimer);
394
+ handshakeTimer = null;
395
+ }
396
+ if (reconnectTimer !== null) {
397
+ clearTimeout(reconnectTimer);
398
+ reconnectTimer = null;
399
+ }
400
+ rejectPendingManageRequests("connection closed before a response arrived");
401
+ if (connection !== null) await connection.close();
402
+ if (state.status === "connected" || state.status === "connecting" || state.status === "reconnecting") state = {
403
+ status: "closed",
404
+ address: state.address,
405
+ reason: "closed by you"
406
+ };
407
+ emit();
408
+ }
409
+ }
410
+ };
411
+ }
412
+ function createMeshSession(transport, identity, clock = { now: () => Date.now() }, reconnect = null) {
413
+ const { session } = createSessionCore(identity, clock, reconnect, async (address) => transport.connect(address));
414
+ return session;
415
+ }
416
+ /** Wires an already-accepted Connection up as a full MeshSession, mirroring exactly what createMeshSession's own dial path does once a connection exists (send handshake, send self-advert, negotiate, consume frames) -- the wire-mesh#45 prerequisite agent-comms needs, since its peers both listen and dial rather than only ever dialing the way web-console's own console UI does. Reconnect does not apply here: if this connection drops, only the remote redialing and being accepted again produces a new connection, and therefore a new session -- there is nothing on this side to retry. */
417
+ async function acceptMeshSession(connection, identity, localDomains, options = {}) {
418
+ const clock = options.clock ?? { now: () => Date.now() };
419
+ let resolvePeerDeviceId = null;
420
+ const peerDeviceId = new Promise((resolve) => {
421
+ resolvePeerDeviceId = resolve;
422
+ });
423
+ let peerDeviceIdResolved = false;
424
+ const { session, wireUpConnection } = createSessionCore(identity, clock, null, null, (advert) => {
425
+ if (peerDeviceIdResolved) return;
426
+ peerDeviceIdResolved = true;
427
+ resolvePeerDeviceId?.(advert.device);
428
+ });
429
+ await wireUpConnection(connection, options.label ?? "accepted", localDomains);
430
+ return {
431
+ ...session,
432
+ peerDeviceId
433
+ };
434
+ }
435
+ //#endregion
436
+ export { HANDSHAKE_TIMEOUT_MS, acceptMeshSession, createMeshSession };
@@ -0,0 +1,99 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/domain/relay-hub.ts
3
+ const HEX_RADIX = 16;
4
+ const HEX_DIGITS_PER_BYTE = 2;
5
+ function deviceKey(device) {
6
+ let key = "";
7
+ for (const byte of device) key += byte.toString(HEX_RADIX).padStart(HEX_DIGITS_PER_BYTE, "0");
8
+ return key;
9
+ }
10
+ function createRelayHub() {
11
+ const devices = /* @__PURE__ */ new Map();
12
+ const connectionDevice = /* @__PURE__ */ new Map();
13
+ const pairings = /* @__PURE__ */ new Map();
14
+ const mostRecentPairing = /* @__PURE__ */ new Map();
15
+ function pairingsOf(connection) {
16
+ const existing = pairings.get(connection);
17
+ if (existing) return existing;
18
+ const created = /* @__PURE__ */ new Map();
19
+ pairings.set(connection, created);
20
+ return created;
21
+ }
22
+ function addPairing(a, aDevice, b, bDevice) {
23
+ pairingsOf(a).set(deviceKey(bDevice), b);
24
+ pairingsOf(b).set(deviceKey(aDevice), a);
25
+ mostRecentPairing.set(a, b);
26
+ mostRecentPairing.set(b, a);
27
+ }
28
+ function forgetConnection(connection) {
29
+ for (const [key, registration] of devices) if (registration.connection === connection) devices.delete(key);
30
+ const ownDevice = connectionDevice.get(connection);
31
+ connectionDevice.delete(connection);
32
+ mostRecentPairing.delete(connection);
33
+ const own = pairings.get(connection);
34
+ if (own) {
35
+ for (const peer of own.values()) {
36
+ const peerOwn = pairings.get(peer);
37
+ if (peerOwn && ownDevice !== void 0) peerOwn.delete(deviceKey(ownDevice));
38
+ if (mostRecentPairing.get(peer) === connection) mostRecentPairing.delete(peer);
39
+ }
40
+ pairings.delete(connection);
41
+ }
42
+ }
43
+ async function handleFrame(connection, frame) {
44
+ if (frame.type === "gossip") {
45
+ for (const advert of frame.peers) devices.set(deviceKey(advert.device), {
46
+ connection,
47
+ device: advert.device
48
+ });
49
+ for (const registration of devices.values()) if (registration.connection === connection) {
50
+ connectionDevice.set(connection, registration.device);
51
+ break;
52
+ }
53
+ return;
54
+ }
55
+ if (frame.type === "relay-connect") {
56
+ const registration = devices.get(deviceKey(frame["target-device"]));
57
+ if (!registration || registration.connection === connection) return;
58
+ const initiatorDevice = connectionDevice.get(connection);
59
+ if (initiatorDevice === void 0) return;
60
+ addPairing(connection, initiatorDevice, registration.connection, registration.device);
61
+ await registration.connection.send({
62
+ type: "relay-inbound",
63
+ "source-device": initiatorDevice
64
+ });
65
+ return;
66
+ }
67
+ if (frame.type === "relay-data") {
68
+ const own = pairings.get(connection);
69
+ const toDevice = frame["to-device"];
70
+ const peer = toDevice !== void 0 ? own?.get(deviceKey(toDevice)) : mostRecentPairing.get(connection);
71
+ if (!peer) return;
72
+ const senderDevice = connectionDevice.get(connection);
73
+ if (senderDevice === void 0) return;
74
+ await peer.send({
75
+ type: "relay-data",
76
+ payload: frame.payload,
77
+ "from-device": senderDevice
78
+ });
79
+ return;
80
+ }
81
+ }
82
+ return {
83
+ async handleConnection(connection) {
84
+ try {
85
+ for await (const frame of connection.receive()) await handleFrame(connection, frame);
86
+ } catch {} finally {
87
+ forgetConnection(connection);
88
+ }
89
+ },
90
+ stop() {
91
+ devices.clear();
92
+ connectionDevice.clear();
93
+ pairings.clear();
94
+ mostRecentPairing.clear();
95
+ }
96
+ };
97
+ }
98
+ //#endregion
99
+ exports.createRelayHub = createRelayHub;
@@ -0,0 +1,10 @@
1
+ import { Connection } from "../ports/transport.cjs";
2
+ //#region src/domain/relay-hub.d.ts
3
+ export interface RelayHub {
4
+ /** Drives one accepted connection until it closes: registers gossip-advertised devices, answers relay-connect by pairing and notifying the target, and forwards relay-data within established pairings. Resolves when the connection's frame stream ends. */
5
+ handleConnection: (connection: Readonly<Connection>) => Promise<void>;
6
+ /** Drops all registry and pairing state -- used by tests and by transport teardown. */
7
+ stop: () => void;
8
+ }
9
+ export declare function createRelayHub(): RelayHub;
10
+ //#endregion
@@ -0,0 +1,10 @@
1
+ import { Connection } from "../ports/transport.mjs";
2
+ //#region src/domain/relay-hub.d.ts
3
+ export interface RelayHub {
4
+ /** Drives one accepted connection until it closes: registers gossip-advertised devices, answers relay-connect by pairing and notifying the target, and forwards relay-data within established pairings. Resolves when the connection's frame stream ends. */
5
+ handleConnection: (connection: Readonly<Connection>) => Promise<void>;
6
+ /** Drops all registry and pairing state -- used by tests and by transport teardown. */
7
+ stop: () => void;
8
+ }
9
+ export declare function createRelayHub(): RelayHub;
10
+ //#endregion
@@ -0,0 +1,98 @@
1
+ //#region src/domain/relay-hub.ts
2
+ const HEX_RADIX = 16;
3
+ const HEX_DIGITS_PER_BYTE = 2;
4
+ function deviceKey(device) {
5
+ let key = "";
6
+ for (const byte of device) key += byte.toString(HEX_RADIX).padStart(HEX_DIGITS_PER_BYTE, "0");
7
+ return key;
8
+ }
9
+ function createRelayHub() {
10
+ const devices = /* @__PURE__ */ new Map();
11
+ const connectionDevice = /* @__PURE__ */ new Map();
12
+ const pairings = /* @__PURE__ */ new Map();
13
+ const mostRecentPairing = /* @__PURE__ */ new Map();
14
+ function pairingsOf(connection) {
15
+ const existing = pairings.get(connection);
16
+ if (existing) return existing;
17
+ const created = /* @__PURE__ */ new Map();
18
+ pairings.set(connection, created);
19
+ return created;
20
+ }
21
+ function addPairing(a, aDevice, b, bDevice) {
22
+ pairingsOf(a).set(deviceKey(bDevice), b);
23
+ pairingsOf(b).set(deviceKey(aDevice), a);
24
+ mostRecentPairing.set(a, b);
25
+ mostRecentPairing.set(b, a);
26
+ }
27
+ function forgetConnection(connection) {
28
+ for (const [key, registration] of devices) if (registration.connection === connection) devices.delete(key);
29
+ const ownDevice = connectionDevice.get(connection);
30
+ connectionDevice.delete(connection);
31
+ mostRecentPairing.delete(connection);
32
+ const own = pairings.get(connection);
33
+ if (own) {
34
+ for (const peer of own.values()) {
35
+ const peerOwn = pairings.get(peer);
36
+ if (peerOwn && ownDevice !== void 0) peerOwn.delete(deviceKey(ownDevice));
37
+ if (mostRecentPairing.get(peer) === connection) mostRecentPairing.delete(peer);
38
+ }
39
+ pairings.delete(connection);
40
+ }
41
+ }
42
+ async function handleFrame(connection, frame) {
43
+ if (frame.type === "gossip") {
44
+ for (const advert of frame.peers) devices.set(deviceKey(advert.device), {
45
+ connection,
46
+ device: advert.device
47
+ });
48
+ for (const registration of devices.values()) if (registration.connection === connection) {
49
+ connectionDevice.set(connection, registration.device);
50
+ break;
51
+ }
52
+ return;
53
+ }
54
+ if (frame.type === "relay-connect") {
55
+ const registration = devices.get(deviceKey(frame["target-device"]));
56
+ if (!registration || registration.connection === connection) return;
57
+ const initiatorDevice = connectionDevice.get(connection);
58
+ if (initiatorDevice === void 0) return;
59
+ addPairing(connection, initiatorDevice, registration.connection, registration.device);
60
+ await registration.connection.send({
61
+ type: "relay-inbound",
62
+ "source-device": initiatorDevice
63
+ });
64
+ return;
65
+ }
66
+ if (frame.type === "relay-data") {
67
+ const own = pairings.get(connection);
68
+ const toDevice = frame["to-device"];
69
+ const peer = toDevice !== void 0 ? own?.get(deviceKey(toDevice)) : mostRecentPairing.get(connection);
70
+ if (!peer) return;
71
+ const senderDevice = connectionDevice.get(connection);
72
+ if (senderDevice === void 0) return;
73
+ await peer.send({
74
+ type: "relay-data",
75
+ payload: frame.payload,
76
+ "from-device": senderDevice
77
+ });
78
+ return;
79
+ }
80
+ }
81
+ return {
82
+ async handleConnection(connection) {
83
+ try {
84
+ for await (const frame of connection.receive()) await handleFrame(connection, frame);
85
+ } catch {} finally {
86
+ forgetConnection(connection);
87
+ }
88
+ },
89
+ stop() {
90
+ devices.clear();
91
+ connectionDevice.clear();
92
+ pairings.clear();
93
+ mostRecentPairing.clear();
94
+ }
95
+ };
96
+ }
97
+ //#endregion
98
+ export { createRelayHub };