wire-mesh-core 4.2.0 → 4.2.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.
@@ -140,6 +140,15 @@ async function verifyHello(identity, hello) {
140
140
  };
141
141
  }
142
142
  }
143
+ /** True when `key` is a usable P-256 ECDH public key, so a channel can be built from a hello carrying it. Cheap enough to run before any key of this side's own is generated. */
144
+ async function isUsableEphemeralKey(key) {
145
+ try {
146
+ await crypto.subtle.importKey("raw", Uint8Array.from(key), ECDH_PARAMS, false, []);
147
+ return true;
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
143
152
  /** The largest counter a channel uses: the counter is an unsigned 64-bit integer on the wire, and this implementation holds it as a JavaScript number, exact up to this value. A frame above it is refused at decoding, never truncated, and sealing refuses to go past it, never wraps. */
144
153
  const MAX_COUNTER = Number.MAX_SAFE_INTEGER;
145
154
  /** Throws a RangeError unless `counter` is a counter a channel can start from: a non-negative safe integer. */
@@ -278,140 +287,382 @@ async function establishChannelFrom(identity, { pending, remote, peer, firstSend
278
287
  }
279
288
  //#endregion
280
289
  //#region src/domain/relay-channels.ts
290
+ /** How long a peer may take to answer a hello, and the window every other rule here is measured in: a handshake is outstanding for this long, a replaced channel may still open frames that were in flight for this long, and an entry younger than this is never reused. Whoever holds a hello for longer than this gains nothing, since the handshake it belongs to is over. */
291
+ const HANDSHAKE_WINDOW_MS = 3e3;
292
+ /** Everything held for one peer. The fields only change through the methods, so each transition has one place. */
293
+ var PeerChannels = class {
294
+ peer;
295
+ chosen = false;
296
+ lastUsed;
297
+ /** The current channel: sealing goes through it and only it. */
298
+ current;
299
+ /** The channel the current one replaced, which may still open frames that were in flight but never seals. */
300
+ replaced;
301
+ hs;
302
+ /** The candidates of a handshake that ended without confirming either of two, which together serve as the channel until a frame opens under one. */
303
+ pool = [];
304
+ waiters;
305
+ opening = Promise.resolve();
306
+ constructor(peer, now) {
307
+ this.peer = peer;
308
+ this.lastUsed = now;
309
+ }
310
+ get key() {
311
+ return require_domain_device_id.deviceIdToHex(this.peer);
312
+ }
313
+ touch(now) {
314
+ this.lastUsed = now;
315
+ }
316
+ choose() {
317
+ this.chosen = true;
318
+ }
319
+ /** True when `key` is the remote ephemeral key of anything held for the peer. */
320
+ holdsKey(key) {
321
+ return [
322
+ this.current?.remoteKey,
323
+ this.replaced?.remoteKey,
324
+ ...(this.hs?.cands ?? []).map((cand) => cand.remoteKey),
325
+ ...this.pool.map((cand) => cand.remoteKey)
326
+ ].some((held) => held !== void 0 && require_domain_token_scope.bytesEqual(held, key));
327
+ }
328
+ /** Whether this side's handshake with the peer is outstanding: sent less than a window ago, and neither confirmed nor ended. */
329
+ outstanding(now) {
330
+ return this.hs !== void 0 && !this.hs.expired && now < this.hs.sentAt + 3e3;
331
+ }
332
+ /** Applies whatever the passing of time has ended: the replaced channel after its window, and a handshake after its window. Checked on every event, so no timer is needed. */
333
+ expire(now) {
334
+ if (this.replaced !== void 0 && now >= this.replaced.until) this.replaced = void 0;
335
+ const { hs } = this;
336
+ if (hs === void 0 || hs.expired || now < hs.sentAt + 3e3) return;
337
+ const [only, ...rest] = hs.cands;
338
+ if (this.current !== void 0) this.hs = void 0;
339
+ else if (only === void 0) hs.expired = true;
340
+ else if (rest.length === 0) {
341
+ this.current = {
342
+ ...only,
343
+ confirmed: false
344
+ };
345
+ this.hs = void 0;
346
+ } else {
347
+ this.pool = hs.cands;
348
+ this.hs = void 0;
349
+ }
350
+ }
351
+ /** The candidates a received frame is tried against, in order. */
352
+ candidates() {
353
+ return [...this.hs?.cands ?? [], ...this.pool];
354
+ }
355
+ begin(hs) {
356
+ this.hs = hs;
357
+ }
358
+ /** Ends `hs` if it is still this peer's handshake. */
359
+ end(hs) {
360
+ if (this.hs !== hs) return false;
361
+ this.hs = void 0;
362
+ return true;
363
+ }
364
+ confirmCurrent() {
365
+ if (this.current !== void 0) this.current.confirmed = true;
366
+ }
367
+ /** A frame opened under `cand`: it becomes the current channel, the channel it replaces may open in-flight frames for a window, and every other candidate goes. */
368
+ confirm(cand, now) {
369
+ if (this.current !== void 0) this.replaced = {
370
+ channel: this.current.channel,
371
+ remoteKey: this.current.remoteKey,
372
+ until: now + HANDSHAKE_WINDOW_MS
373
+ };
374
+ this.current = {
375
+ ...cand,
376
+ confirmed: true
377
+ };
378
+ this.hs = void 0;
379
+ this.pool = [];
380
+ }
381
+ /** Forgets the confirmed channel and the one it replaced, keeping every candidate. */
382
+ dropConfirmed() {
383
+ this.current = void 0;
384
+ this.replaced = void 0;
385
+ }
386
+ /** Forgets the current channel when it is the one that is spent. */
387
+ clearCurrent(channel) {
388
+ if (this.current?.channel === channel) this.current = void 0;
389
+ }
390
+ isEmpty() {
391
+ return this.current === void 0 && this.replaced === void 0 && this.hs === void 0 && this.pool.length === 0;
392
+ }
393
+ /** Resolves once some candidate exists. */
394
+ async waitForChannel() {
395
+ this.waiters ??= (() => {
396
+ const waiting = Promise.withResolvers();
397
+ waiting.promise.catch(() => void 0);
398
+ return waiting;
399
+ })();
400
+ return this.waiters.promise;
401
+ }
402
+ releaseWaiters() {
403
+ this.waiters?.resolve(void 0);
404
+ this.waiters = void 0;
405
+ }
406
+ rejectWaiters(error) {
407
+ this.waiters?.reject(error);
408
+ this.waiters = void 0;
409
+ }
410
+ /** Runs `task` after every earlier one for this peer: two frames opening at once must not both be tried before either has settled what it confirms. */
411
+ async serialise(task) {
412
+ const run = this.opening.then(task);
413
+ this.opening = run.catch(() => void 0);
414
+ return run;
415
+ }
416
+ };
417
+ function authentic(outcome) {
418
+ return outcome.ok || outcome.reason === "undecodable";
419
+ }
420
+ async function openFirst(cands, data) {
421
+ const [first, ...rest] = cands;
422
+ if (first === void 0) return;
423
+ const outcome = await first.channel.openOutcome(data);
424
+ return authentic(outcome) ? {
425
+ cand: first,
426
+ outcome
427
+ } : openFirst(rest, data);
428
+ }
429
+ /** A clock that never goes backwards: milliseconds from an arbitrary origin, as `performance.now()` gives them. What `createMeshSession` gives its channels. */
430
+ const monotonicClock = { now: () => performance.now() };
281
431
  function createRelayChannels(identity, sendHello, options = {}) {
282
432
  if (options.firstSendCounter !== void 0) assertFirstSendCounter(options.firstSendCounter);
283
- const channels = /* @__PURE__ */ new Map();
284
- /** Tells the owner of a failure that is local to this side. The callback is the owner's code: its own failure, thrown or as a rejected promise it returns, must not end the session or go unobserved, so it goes through the shared containment in fault-report.ts. */
285
- function reportFault(error) {
286
- require_domain_fault_report.deliverContained(async () => options.onFault?.(error), 0);
433
+ const newHandshake = options.newHandshake ?? (async (peer) => beginHandshake(identity, peer));
434
+ let latest = Number.NEGATIVE_INFINITY;
435
+ const source = options.clock ?? monotonicClock;
436
+ const clock = { now: () => {
437
+ latest = Math.max(latest, source.now());
438
+ return latest;
439
+ } };
440
+ const peers = /* @__PURE__ */ new Map();
441
+ const reporter = require_domain_fault_report.createFaultReporter(() => clock.now());
442
+ /** Tells the owner of a failure that is local to this side, through the shared fault reporter: at most one report per peer and interval, with the number withheld, and the callback's own failure contained there. */
443
+ function reportFault(error, peer) {
444
+ reporter(peer === void 0 ? "" : require_domain_device_id.deviceIdToHex(peer), async (suppressed) => options.onFault?.(error, {
445
+ peer,
446
+ suppressed
447
+ }));
287
448
  }
288
- /** Abandons a handshake that failed: the entry goes if it is still the one held for the peer, and whoever awaits it is told. */
289
- function abandon(entry, error) {
449
+ function prune(entry) {
450
+ if (entry.isEmpty() && !entry.chosen && peers.get(entry.key) === entry) peers.delete(entry.key);
451
+ }
452
+ /** Ends this side's handshake `hs` with the peer, leaving every channel as it is, and tells whoever waits for a channel when there is none to wait for. */
453
+ function dropHandshake(entry, hs, error) {
454
+ if (!entry.end(hs)) return;
290
455
  const failure = error instanceof Error ? error : new Error(String(error));
291
- if (channels.get(entry.key) === entry) channels.delete(entry.key);
292
- entry.settle.reject(failure);
293
- entry.sent.reject(failure);
294
- }
295
- /** Starts a handshake with the peer: sends this side's hello, and registers the peer as awaiting at once, so a hello arriving meanwhile completes this handshake instead of starting a second. `answering` is true when a received hello started this, so nobody awaits the outcome and a failure to send is reported to the owner. `track` is false for a handshake answering a hello while a channel with that peer is established: it is not registered, so the established channel stays in place and in use until the new one is up and this side's hello has been sent. A hello that could not be sent leaves no channel behind: the peer would never learn this side's key. */
296
- function start(peer, { answering, track } = {
297
- answering: false,
298
- track: true
299
- }) {
300
- const key = require_domain_device_id.deviceIdToHex(peer);
301
- const pending = beginHandshake(identity, peer);
302
- const entry = {
303
- status: "awaiting",
304
- pending,
305
- settle: Promise.withResolvers(),
306
- key,
307
- sent: Promise.withResolvers()
456
+ hs.sent.reject(failure);
457
+ if (entry.current === void 0 && entry.pool.length === 0) entry.rejectWaiters(failure);
458
+ prune(entry);
459
+ }
460
+ /** Drops every handshake that is waiting for its first answer beyond MAX_PENDING_HANDSHAKES, oldest first. */
461
+ function enforcePendingBound(keep) {
462
+ const pending = [...peers.values()].filter((entry) => entry.hs?.origin === "initiated" && entry.hs.cands.length === 0 && entry.current === void 0);
463
+ const excess = pending.filter((entry) => entry !== keep).sort((a, b) => (a.hs?.sentAt ?? 0) - (b.hs?.sentAt ?? 0)).slice(0, Math.max(0, pending.length - 256));
464
+ for (const entry of excess) if (entry.hs !== void 0) dropHandshake(entry, entry.hs, /* @__PURE__ */ new Error("the handshake was dropped to make room for others"));
465
+ }
466
+ /** Begins this side's own hello to the entry's peer and registers it at once, so a hello arriving meanwhile completes it instead of starting another. */
467
+ function beginOwn(entry, origin) {
468
+ const mine = newHandshake(entry.peer);
469
+ const hs = {
470
+ mine,
471
+ origin,
472
+ sentAt: clock.now(),
473
+ cands: [],
474
+ sent: Promise.withResolvers(),
475
+ expired: false
308
476
  };
309
- entry.settle.promise.catch(() => void 0);
310
- entry.sent.promise.catch(() => void 0);
311
- pending.then(async ({ hello }) => {
477
+ hs.sent.promise.catch(() => void 0);
478
+ entry.begin(hs);
479
+ mine.then(async ({ hello }) => {
312
480
  try {
313
- await sendHello(peer, hello);
481
+ await sendHello(entry.peer, hello);
314
482
  } catch (error) {
315
- abandon(entry, error);
316
- entry.settle.promise.then((channel) => {
317
- const held = channels.get(key);
318
- if (held?.status === "established" && held.channel === channel) channels.delete(key);
319
- }, () => void 0);
320
- if (answering && track) reportFault(error);
483
+ dropHandshake(entry, hs, error);
484
+ if (origin === "answered") reportFault(error, entry.peer);
321
485
  return;
322
486
  }
323
- entry.sent.resolve(void 0);
487
+ hs.sent.resolve(void 0);
324
488
  }, (error) => {
325
- abandon(entry, error);
489
+ dropHandshake(entry, hs, error);
326
490
  });
327
- if (track) channels.set(key, entry);
328
- return entry;
329
- }
330
- /** Completes the handshake a verified hello belongs to. Throws on any failure of this side; the caller abandons the handshake. */
331
- async function complete({ entry, hello, peer, replaces }) {
332
- const { key } = entry;
333
- const established = await establishChannelFrom(identity, {
334
- pending: await entry.pending,
491
+ return hs;
492
+ }
493
+ /** Builds the channel `hello` makes with this side's handshake `hs`, and holds it as a candidate. */
494
+ async function addCandidate(entry, hs, hello, peer) {
495
+ const channel = await establishChannelFrom(identity, {
496
+ pending: await hs.mine,
335
497
  remote: hello,
336
498
  peer,
337
499
  ...options.firstSendCounter === void 0 ? {} : { firstSendCounter: options.firstSendCounter }
338
500
  });
339
- if (established === void 0) {
340
- abandon(entry, /* @__PURE__ */ new Error("the peer sent an unusable ephemeral key"));
341
- return;
342
- }
343
- if (replaces !== void 0) await entry.sent.promise;
344
- if (channels.get(key) !== (replaces ?? entry)) return;
345
- const channel = {
346
- ...established,
347
- seal: async (frame) => {
348
- try {
349
- return await established.seal(frame);
350
- } catch (error) {
351
- const held = channels.get(key);
352
- if (error instanceof require_domain_channel_exhausted_error.ChannelExhaustedError && held?.status === "established" && held.channel === channel) channels.delete(key);
353
- throw error;
354
- }
355
- }
356
- };
357
- channels.set(key, {
358
- status: "established",
501
+ if (channel === void 0 || entry.hs !== hs || peers.get(entry.key) !== entry || hs.cands.length >= 2 || entry.holdsKey(hello["ephemeral-key"])) return;
502
+ hs.cands.push({
359
503
  channel,
360
- remoteEphemeralKey: hello["ephemeral-key"]
504
+ remoteKey: hello["ephemeral-key"]
361
505
  });
362
- entry.settle.resolve(channel);
506
+ entry.releaseWaiters();
507
+ }
508
+ /** The entry of a peer this side never sent anything to that may be reused now, or undefined when every slot is free or none is old enough, which is told by `slotIsFree`. An entry is reused only once it is older than the window, so answers cannot outpace the turnover of slots. */
509
+ function reusableEntry(now) {
510
+ const [victim] = [...peers.values()].filter((entry) => !entry.chosen && now - entry.lastUsed >= 3e3).sort((a, b) => a.lastUsed - b.lastUsed);
511
+ return victim;
512
+ }
513
+ function slotIsFree() {
514
+ return [...peers.values()].filter((entry) => !entry.chosen).length < 64;
515
+ }
516
+ /** Whether a peer this side never sent anything to could be given an entry now, changing nothing. */
517
+ function slotAvailable(now) {
518
+ return slotIsFree() || reusableEntry(now) !== void 0;
519
+ }
520
+ /** Makes room for a peer this side never sent anything to. */
521
+ function reserveUnsolicitedSlot(now) {
522
+ if (slotIsFree()) return true;
523
+ const victim = reusableEntry(now);
524
+ if (victim === void 0) return false;
525
+ discard(victim, /* @__PURE__ */ new Error("the entry was dropped to make room for others"));
526
+ return true;
527
+ }
528
+ function discard(entry, error) {
529
+ peers.delete(entry.key);
530
+ entry.hs?.sent.reject(error);
531
+ entry.rejectWaiters(error);
532
+ }
533
+ async function sealUnder(entry, channel, frame) {
534
+ try {
535
+ return await channel.seal(frame);
536
+ } catch (error) {
537
+ if (error instanceof require_domain_channel_exhausted_error.ChannelExhaustedError) entry.clearCurrent(channel);
538
+ throw error;
539
+ }
540
+ }
541
+ async function sealFor(entry, frame) {
542
+ entry.expire(clock.now());
543
+ entry.touch(clock.now());
544
+ const { current, hs } = entry;
545
+ if (current !== void 0) return [await sealUnder(entry, current.channel, frame)];
546
+ if (entry.pool.length > 0) return Promise.all(entry.pool.map(async (cand) => sealUnder(entry, cand.channel, frame)));
547
+ if (hs === void 0) {
548
+ beginOwn(entry, "initiated");
549
+ enforcePendingBound(entry);
550
+ return sealFor(entry, frame);
551
+ }
552
+ if (hs.cands.length > 0) {
553
+ await hs.sent.promise;
554
+ return Promise.all(hs.cands.map(async (cand) => sealUnder(entry, cand.channel, frame)));
555
+ }
556
+ await entry.waitForChannel();
557
+ return sealFor(entry, frame);
558
+ }
559
+ /** Tries a frame against the peer's channels, in order: the current one, then the candidates, then the replaced one while its window lasts. */
560
+ async function openUnder(entry, data) {
561
+ entry.expire(clock.now());
562
+ const { current } = entry;
563
+ if (current !== void 0) {
564
+ const outcome = await current.channel.openOutcome(data);
565
+ if (authentic(outcome)) {
566
+ entry.confirmCurrent();
567
+ entry.touch(clock.now());
568
+ return outcome.ok ? {
569
+ frame: outcome.frame,
570
+ from: current.channel.peer.deviceId
571
+ } : void 0;
572
+ }
573
+ }
574
+ const found = await openFirst(entry.candidates(), data);
575
+ if (found !== void 0) {
576
+ if (peers.get(entry.key) !== entry) return;
577
+ entry.confirm(found.cand, clock.now());
578
+ entry.touch(clock.now());
579
+ return found.outcome.ok ? {
580
+ frame: found.outcome.frame,
581
+ from: found.cand.channel.peer.deviceId
582
+ } : void 0;
583
+ }
584
+ const { replaced } = entry;
585
+ if (replaced !== void 0 && clock.now() < replaced.until) {
586
+ const outcome = await replaced.channel.openOutcome(data);
587
+ return outcome.ok ? {
588
+ frame: outcome.frame,
589
+ from: replaced.channel.peer.deviceId
590
+ } : void 0;
591
+ }
592
+ }
593
+ async function acceptHello(hello, sender) {
594
+ const key = require_domain_device_id.deviceIdToHex(sender);
595
+ const verdict = await verifyHello(identity, hello);
596
+ if (!verdict.ok || !await isUsableEphemeralKey(hello["ephemeral-key"])) return;
597
+ const now = clock.now();
598
+ let entry = peers.get(key);
599
+ entry?.expire(now);
600
+ if (entry?.holdsKey(hello["ephemeral-key"]) === true) return;
601
+ if (entry?.hs !== void 0 && entry.outstanding(now)) {
602
+ if (entry.hs.cands.length < 2) await addCandidate(entry, entry.hs, hello, verdict.peer);
603
+ return;
604
+ }
605
+ if (entry === void 0) {
606
+ if (!reserveUnsolicitedSlot(now)) return;
607
+ entry = new PeerChannels(sender, now);
608
+ peers.set(key, entry);
609
+ }
610
+ const hs = beginOwn(entry, "answered");
611
+ entry.touch(now);
612
+ try {
613
+ await addCandidate(entry, hs, hello, verdict.peer);
614
+ } catch (error) {
615
+ dropHandshake(entry, hs, error);
616
+ throw error;
617
+ }
363
618
  }
364
619
  return {
365
- ensure: async (peer) => {
620
+ seal: async (peer, frame, purpose) => {
366
621
  const key = require_domain_device_id.deviceIdToHex(peer);
367
- const existing = channels.get(key);
368
- if (existing?.status === "established") return existing.channel;
369
- return (existing ?? start(peer)).settle.promise;
622
+ const now = clock.now();
623
+ let entry = peers.get(key);
624
+ if (entry === void 0) {
625
+ if (purpose === "reply" && !reserveUnsolicitedSlot(now)) throw new Error("there is no channel with this peer");
626
+ entry = new PeerChannels(peer, now);
627
+ peers.set(key, entry);
628
+ }
629
+ if (purpose === "request") entry.choose();
630
+ return sealFor(entry, frame);
370
631
  },
371
- applyHello: async (hello) => {
372
- let entry;
632
+ applyHello: async (hello, claimedFrom) => {
633
+ let sender;
373
634
  try {
374
- const verdict = await verifyHello(identity, hello);
375
- if (!verdict.ok) return;
376
- const key = require_domain_device_id.deviceIdToHex(verdict.peer.deviceId);
377
- const current = channels.get(key);
378
- if (current?.status === "established" && require_domain_token_scope.bytesEqual(current.remoteEphemeralKey, hello["ephemeral-key"])) return;
379
- const replaces = current?.status === "established" ? current : void 0;
380
- entry = current?.status === "awaiting" ? current : start(verdict.peer.deviceId, {
381
- answering: true,
382
- track: replaces === void 0
383
- });
384
- await complete({
385
- entry,
386
- hello,
387
- peer: verdict.peer,
388
- replaces
389
- });
635
+ if (claimedFrom === void 0) return;
636
+ sender = await identity.deriveDeviceId(hello["identity-key"]["public-key"]);
637
+ if (!require_domain_token_scope.bytesEqual(sender, claimedFrom)) return;
638
+ const held = peers.get(require_domain_device_id.deviceIdToHex(sender));
639
+ const now = clock.now();
640
+ held?.expire(now);
641
+ if (held?.holdsKey(hello["ephemeral-key"]) === true) return;
642
+ if (held?.hs !== void 0 && held.outstanding(now) && held.hs.cands.length >= 2) return;
643
+ if (held === void 0 && !slotAvailable(now)) return;
644
+ await acceptHello(hello, sender);
390
645
  } catch (error) {
391
- if (entry !== void 0) abandon(entry, error);
392
- reportFault(error);
646
+ reportFault(error, sender);
393
647
  }
394
648
  },
395
649
  open: async (data, claimedFrom) => {
396
650
  if (claimedFrom === void 0) return;
397
- const key = require_domain_device_id.deviceIdToHex(claimedFrom);
398
- const entry = channels.get(key);
399
- if (entry?.status !== "established") return;
400
- const frame = await entry.channel.open(data);
401
- return frame === void 0 ? void 0 : {
402
- frame,
403
- from: entry.channel.peer.deviceId
404
- };
651
+ const entry = peers.get(require_domain_device_id.deviceIdToHex(claimedFrom));
652
+ if (entry === void 0) return;
653
+ return entry.serialise(async () => openUnder(entry, data));
405
654
  },
406
655
  reset: (reason) => {
407
- for (const entry of [...channels.values()]) if (entry.status === "awaiting") abandon(entry, new Error(reason));
408
- channels.clear();
656
+ for (const entry of [...peers.values()]) discard(entry, new Error(reason));
409
657
  },
410
658
  forget: (peer) => {
411
- const key = require_domain_device_id.deviceIdToHex(peer);
412
- const entry = channels.get(key);
413
- if (entry?.status === "awaiting") abandon(entry, /* @__PURE__ */ new Error("the relay pairing was lost"));
414
- channels.delete(key);
659
+ const entry = peers.get(require_domain_device_id.deviceIdToHex(peer));
660
+ if (entry === void 0) return;
661
+ if ((entry.hs?.cands.length ?? 0) + entry.pool.length === 0) {
662
+ discard(entry, /* @__PURE__ */ new Error("the relay pairing was lost"));
663
+ return;
664
+ }
665
+ entry.dropConfirmed();
415
666
  }
416
667
  };
417
668
  }
@@ -500,15 +751,15 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
500
751
  return connection;
501
752
  }
502
753
  /** Sends a frame. Through a relay it is sealed for toDevice on their secure channel first (spec/secure-channel.cddl) and only then wrapped as relay-data, so the hub carries ciphertext. This is the single choke point every outbound manage-request and manage-response passes through, so a consumer of sendManageRequest or respond never needs its own relay logic. */
503
- async function transmit(frame, viaRelay, toDevice) {
754
+ async function transmit(frame, toDevice, purpose = "request") {
504
755
  if (connection === null) throw new Error("not connected");
505
- if (!viaRelay) {
756
+ if (toDevice === void 0) {
506
757
  await connection.send(frame);
507
758
  return;
508
759
  }
509
- if (toDevice === void 0) throw new Error("a relayed frame needs the device it is for");
510
- const sealed = await (await relayChannels.ensure(toDevice)).seal(frame);
511
- await connection.send(require_adapters_frame_codec.wrapRelayData(sealed, toDevice));
760
+ const link = connection;
761
+ const sealed = await relayChannels.seal(toDevice, frame, purpose);
762
+ await Promise.all(sealed.map(async (copy) => link.send(require_adapters_frame_codec.wrapRelayData(copy, toDevice))));
512
763
  }
513
764
  /** `from` is the device whose secure channel the response arrived through, absent for one that arrived directly. A pending request only accepts an answer from where it was sent: a peer must not be able to answer a request meant for another by guessing its request-id. */
514
765
  function applyManageResponse(frame, from) {
@@ -542,7 +793,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
542
793
  direction: "sent",
543
794
  frame: response
544
795
  });
545
- await transmit(response, origin !== void 0, fromDevice);
796
+ await transmit(response, fromDevice, "reply");
546
797
  emit();
547
798
  }
548
799
  };
@@ -560,7 +811,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
560
811
  direction: "received",
561
812
  frame: inner
562
813
  });
563
- await relayChannels.applyHello(inner);
814
+ await relayChannels.applyHello(inner, frame["from-device"]);
564
815
  return;
565
816
  }
566
817
  if (inner?.type === "secure-data") {
@@ -847,7 +1098,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
847
1098
  });
848
1099
  const exchange = (async () => {
849
1100
  try {
850
- await transmit(frame, targetDevice !== void 0, targetDevice);
1101
+ await transmit(frame, targetDevice);
851
1102
  } catch (error) {
852
1103
  pendingManageRequests.delete(requestId);
853
1104
  throw error;
@@ -884,7 +1135,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
884
1135
  direction: "sent",
885
1136
  frame
886
1137
  });
887
- await transmit(frame, false);
1138
+ await transmit(frame);
888
1139
  emit();
889
1140
  },
890
1141
  async sendCoordinatorClaim(frame) {
@@ -894,7 +1145,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
894
1145
  direction: "sent",
895
1146
  frame: claim
896
1147
  });
897
- await transmit(claim, false);
1148
+ await transmit(claim);
898
1149
  emit();
899
1150
  },
900
1151
  async sendGossipUpdate(extensions) {
@@ -904,7 +1155,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
904
1155
  direction: "sent",
905
1156
  frame
906
1157
  });
907
- await transmit(frame, false);
1158
+ await transmit(frame);
908
1159
  emit();
909
1160
  },
910
1161
  getTopologyPeers() {
@@ -917,7 +1168,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
917
1168
  direction: "sent",
918
1169
  frame
919
1170
  });
920
- await transmit(frame, targetDevice !== void 0, targetDevice);
1171
+ await transmit(frame, targetDevice);
921
1172
  emit();
922
1173
  },
923
1174
  async close() {