@net-mesh/browser 0.41.0 → 0.41.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.
- package/CHANGELOG.md +30 -0
- package/README.md +5 -3
- package/dist/index.bundle.js +2 -2
- package/dist/leader/session.d.ts +8 -1
- package/dist/leader/session.js +12 -4
- package/dist/lobby.js +24 -10
- package/dist/net_leaf.js +4 -4
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/node.d.ts +13 -1
- package/dist/node.js +28 -7
- package/dist/peer-driver.d.ts +77 -2
- package/dist/peer-driver.js +163 -3
- package/package.json +1 -1
package/dist/leader/session.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/leader/session.js
CHANGED
|
@@ -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 =
|
|
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 (
|
|
441
|
-
|
|
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,13 +2714,13 @@ 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);
|
|
2723
2720
|
},
|
|
2721
|
+
__wbg_error_4c50581549efd2db: function(arg0, arg1) {
|
|
2722
|
+
console.error(getStringFromWasm0(arg0, arg1));
|
|
2723
|
+
},
|
|
2724
2724
|
__wbg_error_6a23b57453026a47: function() { return handleError(function (arg0) {
|
|
2725
2725
|
const ret = getObject(arg0).error;
|
|
2726
2726
|
return isLikeNone(ret) ? 0 : addHeapObject(ret);
|
|
@@ -3275,7 +3275,7 @@ function __wbg_get_imports() {
|
|
|
3275
3275
|
return addHeapObject(ret);
|
|
3276
3276
|
},
|
|
3277
3277
|
__wbindgen_generic_0000000000000003: function(arg0, arg1) {
|
|
3278
|
-
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx:
|
|
3278
|
+
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 867, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
|
3279
3279
|
const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_6504);
|
|
3280
3280
|
return addHeapObject(ret);
|
|
3281
3281
|
},
|
package/dist/net_leaf_bg.wasm
CHANGED
|
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
|
-
|
|
890
|
-
|
|
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
|
/**
|
package/dist/peer-driver.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/peer-driver.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
*
|