@net-mesh/browser 0.41.0 → 0.42.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.
@@ -90,7 +90,7 @@ export declare class MeshSession {
90
90
  * iteration a page is already written to handle.
91
91
  */
92
92
  private readonly streams;
93
- /** One attempt per peer at a time: see {@link PeerAttempts}. */
93
+ /** One attempt per peer at a time, and one offerer per pair: see {@link PeerAttempts}. */
94
94
  private readonly attempts;
95
95
  /**
96
96
  * The generation the streams in that set were opened under.
@@ -228,6 +228,13 @@ export declare class MeshSession {
228
228
  * offer: it is a proxy round trip here, and read ahead of the gate it
229
229
  * could be stale by the time the offer went out. See
230
230
  * {@link PeerAttempts}.
231
+ *
232
+ * **And between two nodes, the lower id offers.** When this node's id
233
+ * is the higher of the pair, an offer the peer sent in the last
234
+ * `PEER_OFFER_FRESH_MS` (10 s) — or one that crosses this call's own —
235
+ * is answered instead of offered over, so two pages reaching each
236
+ * other at once (glare) converge rather than cancel. See
237
+ * {@link PeerAttempts}.
231
238
  */
232
239
  connectPeer(nodeIdHex: string): Promise<PeerConnectOutcome>;
233
240
  /**
@@ -29,7 +29,7 @@
29
29
  import { fromWasmError, OrgCancelledError, OrgLeaderLostError, } from '../errors.js';
30
30
  import { clientStreamOrgTrampoline, duplexOrgTrampoline, OrgDuplex, OrgHandleRegistry, OrgServe, OrgStream, OrgUpload, streamingOrgTrampoline, toWasmOrgCallOptions, toWasmOrgServeOptions, unaryOrgTrampoline, } from '../org.js';
31
31
  import { buildConnectRequest, parseAttemptStatus, parseCounters, parseDescriptors, } from '../node.js';
32
- import { acceptPeer as driveAccept, connectPeer as driveConnect, PeerAttempts, } from '../peer-driver.js';
32
+ import { acceptPeer as driveAccept, answerWaitingOffer, connectPeer as driveConnect, noteOffer, PeerAttempts, } from '../peer-driver.js';
33
33
  import { LeafStream } from '../stream.js';
34
34
  import { idArg, loadLeafWasm } from '../wasm.js';
35
35
  import { isLifecycleEvent, parseSessionEvent, } from './events.js';
@@ -60,8 +60,8 @@ export class MeshSession {
60
60
  * iteration a page is already written to handle.
61
61
  */
62
62
  streams = new Set();
63
- /** One attempt per peer at a time: see {@link PeerAttempts}. */
64
- attempts = new PeerAttempts();
63
+ /** One attempt per peer at a time, and one offerer per pair: see {@link PeerAttempts}. */
64
+ attempts = new PeerAttempts(() => this.inner.node_id_hex() ?? null);
65
65
  /**
66
66
  * The generation the streams in that set were opened under.
67
67
  *
@@ -89,6 +89,7 @@ export class MeshSession {
89
89
  this.inner = inner;
90
90
  this.streamGeneration = inner.generation();
91
91
  inner.on_event((json) => this.hub.deliver(json, parseSessionEvent));
92
+ this.hub.onAny((event) => noteOffer(this.attempts, event));
92
93
  this.hub.onAny((event) => {
93
94
  // `leader_lost` is this tab losing the leader it was talking
94
95
  // to; `not_leader` is this tab discovering it *was* the leader
@@ -265,6 +266,13 @@ export class MeshSession {
265
266
  * offer: it is a proxy round trip here, and read ahead of the gate it
266
267
  * could be stale by the time the offer went out. See
267
268
  * {@link PeerAttempts}.
269
+ *
270
+ * **And between two nodes, the lower id offers.** When this node's id
271
+ * is the higher of the pair, an offer the peer sent in the last
272
+ * `PEER_OFFER_FRESH_MS` (10 s) — or one that crosses this call's own —
273
+ * is answered instead of offered over, so two pages reaching each
274
+ * other at once (glare) converge rather than cancel. See
275
+ * {@link PeerAttempts}.
268
276
  */
269
277
  async connectPeer(nodeIdHex) {
270
278
  return this.attempts.connect(nodeIdHex, async () => {
@@ -276,7 +284,7 @@ export class MeshSession {
276
284
  // older leader simply has no such reading.
277
285
  return undefined;
278
286
  }
279
- }, () => driveConnect(nodeIdHex, this.peerPrimitives(), parseAttemptStatus));
287
+ }, () => driveConnect(nodeIdHex, this.peerPrimitives(), parseAttemptStatus), () => answerWaitingOffer(nodeIdHex, this.peerPrimitives(), parseAttemptStatus));
280
288
  }
281
289
  /**
282
290
  * Answer the offer `nodeIdHex` filed — {@link connectPeer}'s
package/dist/lobby.js CHANGED
@@ -435,19 +435,32 @@ export async function joinLobby(options) {
435
435
  const node = options.node;
436
436
  const keep = [...(options.tags ?? [])];
437
437
  const seeking = [...keep, seekTag(options.game, host)];
438
- let stopped = false;
438
+ let stopped = null;
439
+ // The seeking announcements not yet settled: the withdrawal waits for
440
+ // them, or one landing after it would put the seek tag back — over a
441
+ // lobby this node created meanwhile.
442
+ const seekingInFlight = new Set();
439
443
  const announce = () => {
440
- if (!stopped)
441
- node.announce(seeking).catch(() => { });
444
+ if (stopped !== null)
445
+ return;
446
+ const sent = node.announce(seeking).catch(() => { });
447
+ seekingInFlight.add(sent);
448
+ void sent.finally(() => seekingInFlight.delete(sent));
442
449
  };
443
450
  announce();
444
451
  const timer = setInterval(announce, LOBBY_ANNOUNCE_MS);
452
+ // Withdraws the seek tag, after any seeking announcement still in
453
+ // flight. Settles once the withdrawal has been attempted (a failure is
454
+ // swallowed, as every announcement here is), so a `close()` that awaits
455
+ // it is an ordering barrier: an announcement replaces the node's whole
456
+ // tag set, and one still in flight when the page goes on to
457
+ // `createLobby` would land after the new listing and erase it.
445
458
  const stop = () => {
446
- if (stopped)
447
- return;
448
- stopped = true;
459
+ if (stopped !== null)
460
+ return stopped;
449
461
  clearInterval(timer);
450
- node.announce(keep).catch(() => { });
462
+ stopped = Promise.all(seekingInFlight).then(() => node.announce(keep)).catch(() => { });
463
+ return stopped;
451
464
  };
452
465
  // Reach the host before joining. Until this player's announcement has
453
466
  // reached the host, the host refuses the handshake — so a refusal here
@@ -462,7 +475,7 @@ export async function joinLobby(options) {
462
475
  }
463
476
  catch (error) {
464
477
  if (Date.now() >= deadline) {
465
- stop();
478
+ void stop();
466
479
  throw new LobbyError('not-found', `could not reach the lobby's host ${host}: ${String(error?.message ?? error)}`);
467
480
  }
468
481
  await new Promise(resolve => setTimeout(resolve, 500));
@@ -482,13 +495,14 @@ export async function joinLobby(options) {
482
495
  });
483
496
  }
484
497
  catch (error) {
485
- stop();
498
+ void stop();
486
499
  throw error;
487
500
  }
488
501
  const close = joined.close.bind(joined);
489
502
  joined.close = async () => {
490
- stop();
503
+ const withdrawn = stop();
491
504
  await close();
505
+ await withdrawn;
492
506
  };
493
507
  return joined;
494
508
  }
package/dist/net_leaf.js CHANGED
@@ -2714,9 +2714,6 @@ function __wbg_get_imports() {
2714
2714
  const ret = getObject(arg0).errorCode;
2715
2715
  return ret;
2716
2716
  },
2717
- __wbg_error_25ec685a8c675bef: function(arg0, arg1) {
2718
- console.error(getStringFromWasm0(arg0, arg1));
2719
- },
2720
2717
  __wbg_error_3da0c81bd4184d52: function(arg0) {
2721
2718
  const ret = getObject(arg0).error;
2722
2719
  return isLikeNone(ret) ? 0 : addHeapObject(ret);
@@ -2725,6 +2722,9 @@ function __wbg_get_imports() {
2725
2722
  const ret = getObject(arg0).error;
2726
2723
  return isLikeNone(ret) ? 0 : addHeapObject(ret);
2727
2724
  }, arguments); },
2725
+ __wbg_error_917fe43c4c53b37c: function(arg0, arg1) {
2726
+ console.error(getStringFromWasm0(arg0, arg1));
2727
+ },
2728
2728
  __wbg_fetch_4178962c570137ca: function(arg0, arg1, arg2) {
2729
2729
  const ret = getObject(arg0).fetch(getStringFromWasm0(arg1, arg2));
2730
2730
  return addHeapObject(ret);
Binary file
package/dist/node.d.ts CHANGED
@@ -294,7 +294,7 @@ export declare class BrowserNode {
294
294
  * handle.
295
295
  */
296
296
  private readonly streams;
297
- /** One attempt per peer at a time: see {@link PeerAttempts}. */
297
+ /** One attempt per peer at a time, and one offerer per pair: see {@link PeerAttempts}. */
298
298
  private readonly attempts;
299
299
  /**
300
300
  * The live org handles (plan §4.5), so {@link BrowserNode.close}
@@ -598,6 +598,13 @@ export declare class BrowserNode {
598
598
  * attempt), offering only after an inconclusive one. See
599
599
  * {@link PeerAttempts}.
600
600
  *
601
+ * **And between two nodes, the lower id offers.** When this node's id
602
+ * is the higher of the pair, an offer the peer sent in the last
603
+ * `PEER_OFFER_FRESH_MS` (10 s) — or one that crosses this call's own —
604
+ * is answered instead of offered over, so two pages reaching each
605
+ * other at once (glare) converge rather than cancel. See
606
+ * {@link PeerAttempts}.
607
+ *
601
608
  * Returns a {@link PeerConnectOutcome}. It **rejects** only for
602
609
  * something that is not a disposition of the attempt — a closed
603
610
  * node, a peer that answered `Reject`, a malformed peer id.
@@ -814,6 +821,11 @@ export declare function buildConnectRequest(options: ConnectOptions): LeafWasmCo
814
821
  * are `u64` on the Rust side and a JS number would round the large
815
822
  * ones. Exported for the session surface, whose `counters_json` is a
816
823
  * promise but whose payload is identical.
824
+ *
825
+ * A nested group is flattened into dotted keys: the leaf's per-reason
826
+ * `drops` object becomes `drops.<reason>`, one counter each. Stringified
827
+ * whole, it read `"[object Object]"` — every drop reason lost behind
828
+ * the doc comment's promise of "each drop reason".
817
829
  */
818
830
  export declare function parseCounters(json: string): Record<string, string>;
819
831
  /**
package/dist/node.js CHANGED
@@ -11,7 +11,7 @@ import { EventHub, parseEvent, parseJsonPreservingU64, toBase64, } from './event
11
11
  import { fromWasmError, IdentityError, OrgCancelledError, RtcError } from './errors.js';
12
12
  import { clientStreamOrgTrampoline, duplexOrgTrampoline, OrgDuplex, OrgHandleRegistry, OrgServe, OrgStream, OrgUpload, streamingOrgTrampoline, toWasmOrgCallOptions, toWasmOrgServeOptions, unaryOrgTrampoline, } from './org.js';
13
13
  import { LeafStream } from './stream.js';
14
- import { acceptPeer as driveAccept, connectPeer as driveConnect, handshakePeer as driveHandshake, PeerAttempts, } from './peer-driver.js';
14
+ import { acceptPeer as driveAccept, answerWaitingOffer, connectPeer as driveConnect, handshakePeer as driveHandshake, noteOffer, PeerAttempts, } from './peer-driver.js';
15
15
  import { classifyRtcFailureAll, probeBootstrapReachable, probeStunBindings, } from './udp-probe.js';
16
16
  import { idArg, loadLeafWasm, } from './wasm.js';
17
17
  /**
@@ -65,8 +65,8 @@ export class BrowserNode {
65
65
  * handle.
66
66
  */
67
67
  streams = new Set();
68
- /** One attempt per peer at a time: see {@link PeerAttempts}. */
69
- attempts = new PeerAttempts();
68
+ /** One attempt per peer at a time, and one offerer per pair: see {@link PeerAttempts}. */
69
+ attempts = new PeerAttempts(() => this.nodeId);
70
70
  /**
71
71
  * The live org handles (plan §4.5), so {@link BrowserNode.close}
72
72
  * can retire ownership: pending calls fail typed and each dropped
@@ -514,6 +514,13 @@ export class BrowserNode {
514
514
  * attempt), offering only after an inconclusive one. See
515
515
  * {@link PeerAttempts}.
516
516
  *
517
+ * **And between two nodes, the lower id offers.** When this node's id
518
+ * is the higher of the pair, an offer the peer sent in the last
519
+ * `PEER_OFFER_FRESH_MS` (10 s) — or one that crosses this call's own —
520
+ * is answered instead of offered over, so two pages reaching each
521
+ * other at once (glare) converge rather than cancel. See
522
+ * {@link PeerAttempts}.
523
+ *
517
524
  * Returns a {@link PeerConnectOutcome}. It **rejects** only for
518
525
  * something that is not a disposition of the attempt — a closed
519
526
  * node, a peer that answered `Reject`, a malformed peer id.
@@ -536,7 +543,7 @@ export class BrowserNode {
536
543
  catch (error) {
537
544
  throw fromWasmError(error);
538
545
  }
539
- }, () => driveConnect(nodeIdHex, this.peerPrimitives(), parseAttemptStatus));
546
+ }, () => driveConnect(nodeIdHex, this.peerPrimitives(), parseAttemptStatus), () => answerWaitingOffer(nodeIdHex, this.peerPrimitives(), parseAttemptStatus));
540
547
  }
541
548
  /**
542
549
  * The four primitives the shared drive loop calls, bound to this
@@ -828,6 +835,7 @@ export class BrowserNode {
828
835
  if (event.rtcAddrs.length > 0)
829
836
  this.anchorRtcAddrs = event.rtcAddrs;
830
837
  }
838
+ noteOffer(this.attempts, event);
831
839
  this.hub.dispatch(event);
832
840
  }
833
841
  }
@@ -880,15 +888,28 @@ export function buildConnectRequest(options) {
880
888
  * are `u64` on the Rust side and a JS number would round the large
881
889
  * ones. Exported for the session surface, whose `counters_json` is a
882
890
  * promise but whose payload is identical.
891
+ *
892
+ * A nested group is flattened into dotted keys: the leaf's per-reason
893
+ * `drops` object becomes `drops.<reason>`, one counter each. Stringified
894
+ * whole, it read `"[object Object]"` — every drop reason lost behind
895
+ * the doc comment's promise of "each drop reason".
883
896
  */
884
897
  export function parseCounters(json) {
885
898
  const parsed = JSON.parse(json);
886
899
  if (parsed === null || typeof parsed !== 'object')
887
900
  return {};
888
901
  const out = {};
889
- for (const [key, value] of Object.entries(parsed)) {
890
- out[key] = typeof value === 'string' ? value : String(value);
891
- }
902
+ const flatten = (group, prefix) => {
903
+ for (const [key, value] of Object.entries(group)) {
904
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
905
+ flatten(value, `${prefix}${key}.`);
906
+ }
907
+ else {
908
+ out[`${prefix}${key}`] = typeof value === 'string' ? value : String(value);
909
+ }
910
+ }
911
+ };
912
+ flatten(parsed, '');
892
913
  return out;
893
914
  }
894
915
  /**
@@ -77,6 +77,16 @@ export declare const PEER_TICK_MS = 50;
77
77
  * Two anchor hops and one pump, so it is generous rather than tight.
78
78
  */
79
79
  export declare const PEER_OFFER_WAIT_MS = 5000;
80
+ /**
81
+ * How long an offer that arrived stays worth answering in place of
82
+ * offering back: the offerer's own ICE deadline (`wasm.rs`'s
83
+ * `PEER_ICE_DEADLINE_MS`). An older offer's attempt has ended at its
84
+ * sender, so answering it would only retire this side's own offer for
85
+ * nothing — and the leaf holds an unanswered offer until something
86
+ * takes it, so without a bound a stale one would hijack every later
87
+ * `connectPeer`.
88
+ */
89
+ export declare const PEER_OFFER_FRESH_MS = 10000;
80
90
  /**
81
91
  * The pair's live dialog when it is already direct and open, `undefined`
82
92
  * (or `null`) otherwise: the healthy-pair reading a surface's
@@ -108,9 +118,44 @@ export type HealthyDialog = () => Promise<string | null | undefined> | string |
108
118
  *
109
119
  * Settled attempts are forgotten: the next call starts fresh. Another
110
120
  * tab's node is outside this gate, as it is outside this surface.
121
+ *
122
+ * **Between two nodes, the lower id offers (glare).** The gate above
123
+ * cannot stop the *peer's* offer cancelling this node's: when both
124
+ * ends call `connectPeer` at once — two pages reaching each other, or
125
+ * both re-trying after an attempt ended — each answer retires the
126
+ * answerer's own offer, both run out their ICE deadline, and the pair
127
+ * stays relayed. So when this node's id is the higher of the pair:
128
+ *
129
+ * - its `connectPeer` answers an offer from the peer that arrived
130
+ * within {@link PEER_OFFER_FRESH_MS}, instead of offering back;
131
+ * - while its own offer is under way, an offer arriving from the peer
132
+ * is answered, and the answer's outcome is the call's.
133
+ *
134
+ * And on either side, an `acceptPeer` while this surface's own
135
+ * `connectPeer` for the peer is under way takes that call's outcome:
136
+ * on the lower id the crossing offer is the one to ignore, and on the
137
+ * higher id the `connectPeer` already answers it.
138
+ *
139
+ * Offers are learned from the surface's `signal` events
140
+ * ({@link noteOffer}); with none noted, or this node's id unknown,
141
+ * `connectPeer` offers exactly as before.
111
142
  */
112
143
  export declare class PeerAttempts {
113
144
  #private;
145
+ private readonly self;
146
+ private readonly now;
147
+ /**
148
+ * @param self This node's id, 16 hex, or `null` while unknown (no
149
+ * tie-break then).
150
+ * @param now The clock offer arrivals are stamped with.
151
+ */
152
+ constructor(self?: () => string | null, now?: () => number);
153
+ /**
154
+ * A verified offer from `peer` (16 hex) arrived. Arrivals past
155
+ * {@link PEER_OFFER_FRESH_MS} are dropped here, so the map holds only
156
+ * peers heard from lately rather than every sender for the node's life.
157
+ */
158
+ noteOffer(peer: string): void;
114
159
  /**
115
160
  * Run `drive` as this peer's offer, unless one is already under way,
116
161
  * the pair is already direct, or an answer under way settles it.
@@ -120,11 +165,34 @@ export declare class PeerAttempts {
120
165
  * a round trip, and an `acceptPeer` could take the pair direct and
121
166
  * leave the gate between a caller's early reading and its offer, which
122
167
  * would then replace the healthy link.
168
+ *
169
+ * `answer` answers the offer the peer filed, if one is waiting
170
+ * (`null` when none is): how the higher id of a pair yields to the
171
+ * lower id's offer rather than crossing it.
172
+ */
173
+ connect(peer: string, healthy: HealthyDialog, drive: () => Promise<PeerConnectOutcome>, answer?: () => Promise<PeerConnectOutcome | null>): Promise<PeerConnectOutcome>;
174
+ /**
175
+ * Run `drive` as this peer's answer, unless one is already under way,
176
+ * or this surface's own `connectPeer` for the peer is (see the class
177
+ * comment).
123
178
  */
124
- connect(peer: string, healthy: HealthyDialog, drive: () => Promise<PeerConnectOutcome>): Promise<PeerConnectOutcome>;
125
- /** Run `drive` as this peer's answer, unless one is already under way. */
126
179
  accept(peer: string, drive: () => Promise<PeerConnectOutcome>): Promise<PeerConnectOutcome>;
127
180
  }
181
+ /**
182
+ * The `kind` a `signal` event carries for an offer: `SignalKind::tag`
183
+ * in `control_plane.rs`, which the leaf writes as a bare number.
184
+ */
185
+ export declare const SIGNAL_KIND_OFFER = "1";
186
+ /**
187
+ * Tell `attempts` about an offer, when `event` is a verified one (a
188
+ * `signal` event of kind {@link SIGNAL_KIND_OFFER}). Every other event
189
+ * is ignored, as is a sender id that is not an exact decimal u64.
190
+ */
191
+ export declare function noteOffer(attempts: PeerAttempts, event: {
192
+ readonly type: string;
193
+ readonly kind?: string;
194
+ readonly from?: string;
195
+ }): void;
128
196
  /** Offer, drive to an open channel, then handshake (§9 steps 3-4). */
129
197
  export declare function connectPeer(peer: string, primitives: PeerPrimitives, parse: (json: string) => PeerAttemptStatus): Promise<PeerConnectOutcome>;
130
198
  /**
@@ -137,6 +205,13 @@ export declare function connectPeer(peer: string, primitives: PeerPrimitives, pa
137
205
  * thrown at once, because retrying those would only hide them.
138
206
  */
139
207
  export declare function acceptPeer(peer: string, primitives: PeerPrimitives, parse: (json: string) => PeerAttemptStatus): Promise<PeerConnectOutcome>;
208
+ /**
209
+ * Answer the offer `peer` filed if one is waiting now, without waiting
210
+ * for one: `null` when none is, or when the leaf refuses the answer —
211
+ * the caller ({@link PeerAttempts.connect}, yielding to the lower id)
212
+ * then offers as it would have.
213
+ */
214
+ export declare function answerWaitingOffer(peer: string, primitives: PeerPrimitives, parse: (json: string) => PeerAttemptStatus): Promise<PeerConnectOutcome | null>;
140
215
  /**
141
216
  * Run the offerer's handshake on an attempt whose channel is open.
142
217
  *
@@ -58,6 +58,16 @@ export const PEER_TICK_MS = 50;
58
58
  * Two anchor hops and one pump, so it is generous rather than tight.
59
59
  */
60
60
  export const PEER_OFFER_WAIT_MS = 5_000;
61
+ /**
62
+ * How long an offer that arrived stays worth answering in place of
63
+ * offering back: the offerer's own ICE deadline (`wasm.rs`'s
64
+ * `PEER_ICE_DEADLINE_MS`). An older offer's attempt has ended at its
65
+ * sender, so answering it would only retire this side's own offer for
66
+ * nothing — and the leaf holds an unanswered offer until something
67
+ * takes it, so without a bound a stale one would hijack every later
68
+ * `connectPeer`.
69
+ */
70
+ export const PEER_OFFER_FRESH_MS = 10_000;
61
71
  /**
62
72
  * Whether an answer's outcome settles the pair for a `connectPeer` that
63
73
  * waited on it, rather than calling for an offer of its own.
@@ -110,10 +120,57 @@ function answerSettles(outcome) {
110
120
  *
111
121
  * Settled attempts are forgotten: the next call starts fresh. Another
112
122
  * tab's node is outside this gate, as it is outside this surface.
123
+ *
124
+ * **Between two nodes, the lower id offers (glare).** The gate above
125
+ * cannot stop the *peer's* offer cancelling this node's: when both
126
+ * ends call `connectPeer` at once — two pages reaching each other, or
127
+ * both re-trying after an attempt ended — each answer retires the
128
+ * answerer's own offer, both run out their ICE deadline, and the pair
129
+ * stays relayed. So when this node's id is the higher of the pair:
130
+ *
131
+ * - its `connectPeer` answers an offer from the peer that arrived
132
+ * within {@link PEER_OFFER_FRESH_MS}, instead of offering back;
133
+ * - while its own offer is under way, an offer arriving from the peer
134
+ * is answered, and the answer's outcome is the call's.
135
+ *
136
+ * And on either side, an `acceptPeer` while this surface's own
137
+ * `connectPeer` for the peer is under way takes that call's outcome:
138
+ * on the lower id the crossing offer is the one to ignore, and on the
139
+ * higher id the `connectPeer` already answers it.
140
+ *
141
+ * Offers are learned from the surface's `signal` events
142
+ * ({@link noteOffer}); with none noted, or this node's id unknown,
143
+ * `connectPeer` offers exactly as before.
113
144
  */
114
145
  export class PeerAttempts {
146
+ self;
147
+ now;
115
148
  #connecting = new Map();
116
149
  #accepting = new Map();
150
+ /** When an offer from each peer last arrived, by {@link now}. */
151
+ #offers = new Map();
152
+ /**
153
+ * @param self This node's id, 16 hex, or `null` while unknown (no
154
+ * tie-break then).
155
+ * @param now The clock offer arrivals are stamped with.
156
+ */
157
+ constructor(self = () => null, now = () => Date.now()) {
158
+ this.self = self;
159
+ this.now = now;
160
+ }
161
+ /**
162
+ * A verified offer from `peer` (16 hex) arrived. Arrivals past
163
+ * {@link PEER_OFFER_FRESH_MS} are dropped here, so the map holds only
164
+ * peers heard from lately rather than every sender for the node's life.
165
+ */
166
+ noteOffer(peer) {
167
+ const now = this.now();
168
+ for (const [key, at] of this.#offers) {
169
+ if (at <= now - PEER_OFFER_FRESH_MS)
170
+ this.#offers.delete(key);
171
+ }
172
+ this.#offers.set(peer.toLowerCase(), now);
173
+ }
117
174
  /**
118
175
  * Run `drive` as this peer's offer, unless one is already under way,
119
176
  * the pair is already direct, or an answer under way settles it.
@@ -123,8 +180,12 @@ export class PeerAttempts {
123
180
  * a round trip, and an `acceptPeer` could take the pair direct and
124
181
  * leave the gate between a caller's early reading and its offer, which
125
182
  * would then replace the healthy link.
183
+ *
184
+ * `answer` answers the offer the peer filed, if one is waiting
185
+ * (`null` when none is): how the higher id of a pair yields to the
186
+ * lower id's offer rather than crossing it.
126
187
  */
127
- connect(peer, healthy, drive) {
188
+ connect(peer, healthy, drive, answer) {
128
189
  const key = peer.toLowerCase();
129
190
  const joining = this.#connecting.get(key);
130
191
  if (joining !== undefined)
@@ -139,18 +200,81 @@ export class PeerAttempts {
139
200
  const dialog = await healthy();
140
201
  if (dialog !== undefined && dialog !== null)
141
202
  return { type: 'direct', peer, dialog };
142
- return drive();
203
+ if (answer === undefined || !this.#yields(key))
204
+ return drive();
205
+ // The lower id already offered: answer it rather than cross it.
206
+ if (this.#takeOffer(key, this.now() - PEER_OFFER_FRESH_MS)) {
207
+ const answered = await answer();
208
+ // Only an answer that settles the pair stands in for the offer.
209
+ if (answered !== null && answerSettles(answered))
210
+ return answered;
211
+ }
212
+ return this.#offerYielding(key, drive, answer);
143
213
  })();
144
214
  return PeerAttempts.#track(this.#connecting, key, started);
145
215
  }
146
- /** Run `drive` as this peer's answer, unless one is already under way. */
216
+ /**
217
+ * Run `drive` as this peer's answer, unless one is already under way,
218
+ * or this surface's own `connectPeer` for the peer is (see the class
219
+ * comment).
220
+ */
147
221
  accept(peer, drive) {
148
222
  const key = peer.toLowerCase();
149
223
  const joining = this.#accepting.get(key);
150
224
  if (joining !== undefined)
151
225
  return joining;
226
+ const connecting = this.#connecting.get(key);
227
+ if (connecting !== undefined && this.self() !== null)
228
+ return connecting;
152
229
  return PeerAttempts.#track(this.#accepting, key, drive());
153
230
  }
231
+ /** Whether this node is the higher id of the pair, and so answers. */
232
+ #yields(key) {
233
+ const self = this.self()?.toLowerCase();
234
+ // 16 hex each, so the strings order as the ids do.
235
+ return self !== undefined && self.length === key.length && self > key;
236
+ }
237
+ /** Consume a noted offer that arrived after `since`, if there is one. */
238
+ #takeOffer(key, since) {
239
+ const at = this.#offers.get(key);
240
+ if (at === undefined || at <= since)
241
+ return false;
242
+ this.#offers.delete(key);
243
+ return true;
244
+ }
245
+ /**
246
+ * Offer, but answer an offer from the peer that crosses it: this
247
+ * node is the higher id, so the crossing offer is the one the pair
248
+ * keeps. The offer's own drive then ends `superseded` by the answer,
249
+ * and is not the call's outcome.
250
+ *
251
+ * The answer, once started, is always awaited: answering retires this
252
+ * node's offer, so the offer's drive can settle `superseded` while the
253
+ * answer is still running, and racing the two returned that instead of
254
+ * the answer's result. With no answer started, the call's outcome is
255
+ * the offer's, within one tick of its settling.
256
+ */
257
+ async #offerYielding(key, drive, answer) {
258
+ const since = this.now();
259
+ const offering = drive();
260
+ let settled = false;
261
+ const done = () => {
262
+ settled = true;
263
+ };
264
+ offering.then(done, done);
265
+ const crossed = (async () => {
266
+ while (!settled) {
267
+ await tick();
268
+ if (settled || !this.#takeOffer(key, since))
269
+ continue;
270
+ const answered = await answer();
271
+ if (answered !== null)
272
+ return answered;
273
+ }
274
+ return null;
275
+ })();
276
+ return crossed.then((answered) => answered ?? offering);
277
+ }
154
278
  static #track(map, key, started) {
155
279
  map.set(key, started);
156
280
  // Forget it once settled, and only if no newer one took its place.
@@ -162,6 +286,26 @@ export class PeerAttempts {
162
286
  return started;
163
287
  }
164
288
  }
289
+ /**
290
+ * The `kind` a `signal` event carries for an offer: `SignalKind::tag`
291
+ * in `control_plane.rs`, which the leaf writes as a bare number.
292
+ */
293
+ export const SIGNAL_KIND_OFFER = '1';
294
+ /**
295
+ * Tell `attempts` about an offer, when `event` is a verified one (a
296
+ * `signal` event of kind {@link SIGNAL_KIND_OFFER}). Every other event
297
+ * is ignored, as is a sender id that is not an exact decimal u64.
298
+ */
299
+ export function noteOffer(attempts, event) {
300
+ if (event.type !== 'signal' || event.kind !== SIGNAL_KIND_OFFER)
301
+ return;
302
+ if (event.from === undefined || !/^\d{1,20}$/.test(event.from))
303
+ return;
304
+ const id = BigInt(event.from);
305
+ if (id > 0xffffffffffffffffn)
306
+ return;
307
+ attempts.noteOffer(id.toString(16).padStart(16, '0'));
308
+ }
165
309
  /** Offer, drive to an open channel, then handshake (§9 steps 3-4). */
166
310
  export async function connectPeer(peer, primitives, parse) {
167
311
  let dialog;
@@ -210,6 +354,22 @@ export async function acceptPeer(peer, primitives, parse) {
210
354
  }
211
355
  return driveAttempt(peer, dialog, (status) => status.direct, primitives, parse);
212
356
  }
357
+ /**
358
+ * Answer the offer `peer` filed if one is waiting now, without waiting
359
+ * for one: `null` when none is, or when the leaf refuses the answer —
360
+ * the caller ({@link PeerAttempts.connect}, yielding to the lower id)
361
+ * then offers as it would have.
362
+ */
363
+ export async function answerWaitingOffer(peer, primitives, parse) {
364
+ let dialog;
365
+ try {
366
+ dialog = await primitives.acceptOffer(peer);
367
+ }
368
+ catch {
369
+ return null;
370
+ }
371
+ return driveAttempt(peer, dialog, (status) => status.direct, primitives, parse);
372
+ }
213
373
  /**
214
374
  * Run the offerer's handshake on an attempt whose channel is open.
215
375
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@net-mesh/browser",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Browser-native Net mesh leaf \u2014 idiomatic TypeScript over the net-mesh-leaf wasm node",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",