@net-mesh/browser 0.42.0 → 0.44.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.
- package/CHANGELOG.md +200 -0
- package/README.md +12 -0
- package/dist/index.bundle.js +2 -2
- package/dist/leader/session.d.ts +4 -2
- package/dist/leader/session.js +30 -6
- package/dist/lobby.d.ts +29 -2
- package/dist/lobby.js +80 -10
- package/dist/net_leaf.d.ts +19 -21
- package/dist/net_leaf.js +42 -44
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +10 -10
- package/dist/netcode/assignment.d.ts +169 -0
- package/dist/netcode/assignment.js +321 -0
- package/dist/netcode/client.d.ts +125 -3
- package/dist/netcode/client.js +291 -27
- package/dist/netcode/codec.d.ts +180 -0
- package/dist/netcode/codec.js +547 -0
- package/dist/netcode/host.d.ts +58 -1
- package/dist/netcode/host.js +254 -9
- package/dist/netcode/index.d.ts +14 -4
- package/dist/netcode/index.js +8 -3
- package/dist/netcode/interpolate.d.ts +24 -1
- package/dist/netcode/interpolate.js +52 -1
- package/dist/netcode/paths.d.ts +115 -0
- package/dist/netcode/paths.js +261 -0
- package/dist/netcode/shards.d.ts +138 -0
- package/dist/netcode/shards.js +270 -0
- package/dist/netcode/wire.d.ts +37 -3
- package/dist/netcode/wire.js +28 -1
- package/dist/node.d.ts +20 -0
- package/dist/node.js +76 -1
- package/dist/store/host.js +10 -4
- package/dist/store/join.js +38 -2
- package/dist/store/owner.d.ts +11 -0
- package/dist/store/owner.js +27 -7
- package/dist/store/types.d.ts +6 -0
- package/dist/stream.d.ts +29 -1
- package/dist/stream.js +66 -4
- package/dist/udp-probe.d.ts +21 -3
- package/dist/udp-probe.js +78 -7
- package/dist/world/link.d.ts +9 -0
- package/dist/world/link.js +4 -3
- package/dist/world/regions.d.ts +9 -2
- package/dist/world/regions.js +34 -4
- package/package.json +3 -2
package/dist/netcode/wire.d.ts
CHANGED
|
@@ -40,22 +40,43 @@ export interface WireInput<I> {
|
|
|
40
40
|
readonly data: I;
|
|
41
41
|
}
|
|
42
42
|
export type Frame<E, I> =
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Player → host: join (repeated until the first snapshot). `f` is the
|
|
45
|
+
* player's entity-codec fingerprint, if it has one: the host sends binary
|
|
46
|
+
* snapshots only when it matches its own (`codec.ts`).
|
|
47
|
+
*/
|
|
44
48
|
{
|
|
45
49
|
readonly n: string;
|
|
46
50
|
readonly k: 'h';
|
|
51
|
+
readonly f?: string;
|
|
47
52
|
}
|
|
48
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Player → host: the newest inputs, the unacknowledged ones repeated.
|
|
55
|
+
* `ep`: the assignment epoch the player sent them under (sharded
|
|
56
|
+
* matches); a host at another epoch drops them.
|
|
57
|
+
*/
|
|
49
58
|
| {
|
|
50
59
|
readonly n: string;
|
|
51
60
|
readonly k: 'i';
|
|
52
61
|
readonly i: readonly WireInput<I>[];
|
|
62
|
+
readonly ep?: number;
|
|
53
63
|
}
|
|
54
|
-
/** Player → host: clock ping. */
|
|
64
|
+
/** Player → host: clock ping, repeating the codec fingerprint (a lost join must not pin a player to JSON). */
|
|
55
65
|
| {
|
|
56
66
|
readonly n: string;
|
|
57
67
|
readonly k: 'p';
|
|
58
68
|
readonly t0: number;
|
|
69
|
+
readonly f?: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Player → host: binary-snapshot indexes it has no name for; the host
|
|
73
|
+
* names them again (only indexes it showed that player, and an index at
|
|
74
|
+
* most once per 100 ms).
|
|
75
|
+
*/
|
|
76
|
+
| {
|
|
77
|
+
readonly n: string;
|
|
78
|
+
readonly k: 'u';
|
|
79
|
+
readonly u: readonly number[];
|
|
59
80
|
}
|
|
60
81
|
/**
|
|
61
82
|
* Player → host: the interest keys it wants, version `v` (repeated until a
|
|
@@ -93,10 +114,23 @@ export type Frame<E, I> =
|
|
|
93
114
|
readonly of?: number;
|
|
94
115
|
/** The interest version this snapshot was filtered by, if any. */
|
|
95
116
|
readonly iv?: number;
|
|
117
|
+
/** The assignment epoch the host sent it under (sharded matches). */
|
|
118
|
+
readonly ep?: number;
|
|
96
119
|
};
|
|
97
120
|
/** Interest keys a player may name, and the longest key, in UTF-16 units. */
|
|
98
121
|
export declare const MAX_NETCODE_INTEREST_KEYS = 256;
|
|
99
122
|
export declare const MAX_NETCODE_INTEREST_KEY_LENGTH = 64;
|
|
123
|
+
/** Indexes one `u` frame may ask to have named. */
|
|
124
|
+
export declare const MAX_NAME_REQUESTS = 256;
|
|
125
|
+
/**
|
|
126
|
+
* A binary-snapshot name not used for this long, ms, is forgotten: by the
|
|
127
|
+
* host for an id no player has been shown (it gets a fresh index if it
|
|
128
|
+
* comes back, named again), and by the host for one player once that
|
|
129
|
+
* player has not been shown it. A player keeps a name three times as long
|
|
130
|
+
* from the last frame that carried the index, so it never forgets one the
|
|
131
|
+
* host still counts as known to it; forgetting early would only cost a `u`.
|
|
132
|
+
*/
|
|
133
|
+
export declare const NAME_FORGET_MS = 10000;
|
|
100
134
|
/** Chunks a snapshot may be split into, at most. */
|
|
101
135
|
export declare const MAX_SNAPSHOT_CHUNKS = 256;
|
|
102
136
|
/**
|
package/dist/netcode/wire.js
CHANGED
|
@@ -11,6 +11,17 @@
|
|
|
11
11
|
/** Interest keys a player may name, and the longest key, in UTF-16 units. */
|
|
12
12
|
export const MAX_NETCODE_INTEREST_KEYS = 256;
|
|
13
13
|
export const MAX_NETCODE_INTEREST_KEY_LENGTH = 64;
|
|
14
|
+
/** Indexes one `u` frame may ask to have named. */
|
|
15
|
+
export const MAX_NAME_REQUESTS = 256;
|
|
16
|
+
/**
|
|
17
|
+
* A binary-snapshot name not used for this long, ms, is forgotten: by the
|
|
18
|
+
* host for an id no player has been shown (it gets a fresh index if it
|
|
19
|
+
* comes back, named again), and by the host for one player once that
|
|
20
|
+
* player has not been shown it. A player keeps a name three times as long
|
|
21
|
+
* from the last frame that carried the index, so it never forgets one the
|
|
22
|
+
* host still counts as known to it; forgetting early would only cost a `u`.
|
|
23
|
+
*/
|
|
24
|
+
export const NAME_FORGET_MS = 10_000;
|
|
14
25
|
/** Chunks a snapshot may be split into, at most. */
|
|
15
26
|
export const MAX_SNAPSHOT_CHUNKS = 256;
|
|
16
27
|
/**
|
|
@@ -25,6 +36,10 @@ export function chunkOf(id, of) {
|
|
|
25
36
|
}
|
|
26
37
|
return (hash >>> 0) % of;
|
|
27
38
|
}
|
|
39
|
+
/** An `ep` field: absent, or a non-negative safe integer. */
|
|
40
|
+
function epochField(ep) {
|
|
41
|
+
return ep === undefined || (Number.isSafeInteger(ep) && ep >= 0);
|
|
42
|
+
}
|
|
28
43
|
const encoder = new TextEncoder();
|
|
29
44
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
30
45
|
export function encodeFrame(frame) {
|
|
@@ -47,11 +62,22 @@ export function decodeFrame(payload, label) {
|
|
|
47
62
|
const frame = value;
|
|
48
63
|
if (frame.n !== label || typeof frame.k !== 'string')
|
|
49
64
|
return null;
|
|
65
|
+
const fingerprint = frame.f;
|
|
66
|
+
if (fingerprint !== undefined && (typeof fingerprint !== 'string' || fingerprint.length > 64))
|
|
67
|
+
return null;
|
|
50
68
|
switch (frame.k) {
|
|
51
69
|
case 'h':
|
|
52
70
|
return frame;
|
|
71
|
+
case 'u': {
|
|
72
|
+
const u = frame.u;
|
|
73
|
+
return Array.isArray(u) && u.length <= MAX_NAME_REQUESTS && u.every(index => Number.isSafeInteger(index) && index >= 0)
|
|
74
|
+
? frame
|
|
75
|
+
: null;
|
|
76
|
+
}
|
|
53
77
|
case 'i':
|
|
54
|
-
return Array.isArray(frame.i)
|
|
78
|
+
return Array.isArray(frame.i) && epochField(frame.ep)
|
|
79
|
+
? frame
|
|
80
|
+
: null;
|
|
55
81
|
case 'p':
|
|
56
82
|
return typeof frame.t0 === 'number' ? frame : null;
|
|
57
83
|
case 'w': {
|
|
@@ -73,6 +99,7 @@ export function decodeFrame(payload, label) {
|
|
|
73
99
|
case 's': {
|
|
74
100
|
const s = frame;
|
|
75
101
|
if ((s.iv !== undefined && !Number.isSafeInteger(s.iv)) ||
|
|
102
|
+
!epochField(s.ep) ||
|
|
76
103
|
typeof s.tick !== 'number' ||
|
|
77
104
|
typeof s.t !== 'number' ||
|
|
78
105
|
typeof s.ack !== 'number' ||
|
package/dist/node.d.ts
CHANGED
|
@@ -257,6 +257,19 @@ export interface ConnectOptions extends WasmSource {
|
|
|
257
257
|
* an entity but not a static key.
|
|
258
258
|
*/
|
|
259
259
|
noiseSecretHex?: string;
|
|
260
|
+
/**
|
|
261
|
+
* Hold this identity exclusively while the node is open (default
|
|
262
|
+
* `true`; only read with {@link ConnectOptions.entitySecretHex}).
|
|
263
|
+
*
|
|
264
|
+
* Two tabs connected as the same node evict each other at the anchor,
|
|
265
|
+
* so stores and lobbies in both flap — the hazard `openSession()`
|
|
266
|
+
* avoids by electing one leader. `connect()` takes a Web Lock named
|
|
267
|
+
* after a hash of the identity (never the secret) and refuses a second
|
|
268
|
+
* holder with an {@link IdentityError} instead; `close()` releases it.
|
|
269
|
+
* Where Web Locks are unavailable the lock is skipped. Pass `false` to
|
|
270
|
+
* run without it (SDK audit 2026-10-11).
|
|
271
|
+
*/
|
|
272
|
+
lockIdentity?: boolean;
|
|
260
273
|
/**
|
|
261
274
|
* The `rtc_addr` the anchor publishes, supplied out of band.
|
|
262
275
|
*
|
|
@@ -312,6 +325,8 @@ export declare class BrowserNode {
|
|
|
312
325
|
private anchorRtcAddrs;
|
|
313
326
|
/** Set once the leaf reports a session — a bootstrap that demonstrably worked. */
|
|
314
327
|
private bootstrapObserved;
|
|
328
|
+
/** Releases the identity's Web Lock (`connect({ lockIdentity })`). */
|
|
329
|
+
private releaseIdentity;
|
|
315
330
|
/** This node's mesh id, hex. */
|
|
316
331
|
nodeIdHex(): string;
|
|
317
332
|
/** The anchor's mesh id, hex. */
|
|
@@ -868,6 +883,11 @@ export interface RetryReport {
|
|
|
868
883
|
readonly online: string;
|
|
869
884
|
/** Triggers filed by the ICE `disconnected` → `failed` watcher. */
|
|
870
885
|
readonly iceFailed: string;
|
|
886
|
+
/**
|
|
887
|
+
* Triggers filed because the far end closed a DataChannel a session
|
|
888
|
+
* rode. One channel's loss counts once, here or under `iceFailed`.
|
|
889
|
+
*/
|
|
890
|
+
readonly channelClosed: string;
|
|
871
891
|
/** Every trigger seen, whatever the owner decided. */
|
|
872
892
|
readonly triggers: string;
|
|
873
893
|
/** Re-attempts STARTED. One per network change, per peer. */
|
package/dist/node.js
CHANGED
|
@@ -83,6 +83,12 @@ export class BrowserNode {
|
|
|
83
83
|
anchorRtcAddrs = [];
|
|
84
84
|
/** Set once the leaf reports a session — a bootstrap that demonstrably worked. */
|
|
85
85
|
bootstrapObserved = false;
|
|
86
|
+
/** Releases the identity's Web Lock (`connect({ lockIdentity })`). */
|
|
87
|
+
releaseIdentity = null;
|
|
88
|
+
/** @internal — `connect()` hands over the identity lock it took. */
|
|
89
|
+
releaseIdentityOnClose(release) {
|
|
90
|
+
this.releaseIdentity = release;
|
|
91
|
+
}
|
|
86
92
|
/** @internal — use {@link connect}. */
|
|
87
93
|
constructor(inner, bootstrapUrl, failureTyping, anchorRtcAddr) {
|
|
88
94
|
this.inner = inner;
|
|
@@ -787,6 +793,10 @@ export class BrowserNode {
|
|
|
787
793
|
if (this.closed)
|
|
788
794
|
return;
|
|
789
795
|
this.closed = true;
|
|
796
|
+
// Whatever else fails below, the identity is free for another tab.
|
|
797
|
+
const releaseIdentity = this.releaseIdentity;
|
|
798
|
+
this.releaseIdentity = null;
|
|
799
|
+
queueMicrotask(() => releaseIdentity?.());
|
|
790
800
|
const failures = [];
|
|
791
801
|
// Ownership retirement first, while the node can still put a
|
|
792
802
|
// CANCEL on the wire (plan §4.5): one per dropped caller handle,
|
|
@@ -851,11 +861,17 @@ export async function connect(options) {
|
|
|
851
861
|
const request = buildConnectRequest(options);
|
|
852
862
|
const failureTyping = options.failureTyping ?? {};
|
|
853
863
|
const wasm = await loadLeafWasm(options);
|
|
864
|
+
const release = request.entitySecretHex !== undefined && options.lockIdentity !== false
|
|
865
|
+
? await lockIdentity(request.entitySecretHex)
|
|
866
|
+
: null;
|
|
854
867
|
try {
|
|
855
868
|
const inner = await wasm.LeafNode.connect(request);
|
|
856
|
-
|
|
869
|
+
const node = new BrowserNode(inner, options.bootstrapUrl ?? null, failureTyping, options.anchorRtcAddr ?? null);
|
|
870
|
+
node.releaseIdentityOnClose(release);
|
|
871
|
+
return node;
|
|
857
872
|
}
|
|
858
873
|
catch (error) {
|
|
874
|
+
release?.();
|
|
859
875
|
throw await refineIceFailure(fromWasmError(error), {
|
|
860
876
|
bootstrapUrl: options.bootstrapUrl ?? null,
|
|
861
877
|
bootstrapObserved: false,
|
|
@@ -864,6 +880,64 @@ export async function connect(options) {
|
|
|
864
880
|
});
|
|
865
881
|
}
|
|
866
882
|
}
|
|
883
|
+
/**
|
|
884
|
+
* The Web Lock name for one custodial identity, or `null` where
|
|
885
|
+
* `crypto.subtle` is unavailable. A hash of the secret, never the secret.
|
|
886
|
+
*
|
|
887
|
+
* Shared by `connect()` and `openSession()`: a session with a custodial
|
|
888
|
+
* secret names its election lock with this too, so a `connect()` tab and
|
|
889
|
+
* an `openSession()` tab given one secret contend for ONE lock instead of
|
|
890
|
+
* each running a node with that identity (cubic review of PR #1228).
|
|
891
|
+
*
|
|
892
|
+
* @internal
|
|
893
|
+
*/
|
|
894
|
+
export async function identityLockName(entitySecretHex) {
|
|
895
|
+
const subtle = globalThis.crypto?.subtle;
|
|
896
|
+
if (subtle === undefined)
|
|
897
|
+
return null;
|
|
898
|
+
// Lowercased: the secret parses case-insensitively, so "AB…" and "ab…" are
|
|
899
|
+
// one identity and must contend for one lock (code review 2026-10-11,
|
|
900
|
+
// BROWSER-3).
|
|
901
|
+
const canonical = entitySecretHex.toLowerCase();
|
|
902
|
+
const digest = new Uint8Array(await subtle.digest('SHA-256', new TextEncoder().encode(`net-mesh:identity:${canonical}`)));
|
|
903
|
+
return `net-mesh:identity:${Array.from(digest.subarray(0, 16), b => b.toString(16).padStart(2, '0')).join('')}`;
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Take the exclusive Web Lock for one custodial identity, or throw an
|
|
907
|
+
* {@link IdentityError} when another tab or window holds it. Returns the
|
|
908
|
+
* release, or `null` where Web Locks (or `crypto.subtle`, for the hashed
|
|
909
|
+
* name) are unavailable.
|
|
910
|
+
*/
|
|
911
|
+
async function lockIdentity(entitySecretHex) {
|
|
912
|
+
const locks = globalThis.navigator?.locks;
|
|
913
|
+
if (locks === undefined)
|
|
914
|
+
return null;
|
|
915
|
+
const name = await identityLockName(entitySecretHex);
|
|
916
|
+
if (name === null)
|
|
917
|
+
return null;
|
|
918
|
+
const acquired = await new Promise((resolve, reject) => {
|
|
919
|
+
let release;
|
|
920
|
+
const held = new Promise(done => {
|
|
921
|
+
release = done;
|
|
922
|
+
});
|
|
923
|
+
locks
|
|
924
|
+
.request(name, { ifAvailable: true }, async (lock) => {
|
|
925
|
+
if (lock === null) {
|
|
926
|
+
resolve('busy');
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
resolve(release);
|
|
930
|
+
await held;
|
|
931
|
+
})
|
|
932
|
+
.catch(reject);
|
|
933
|
+
});
|
|
934
|
+
if (acquired === 'busy') {
|
|
935
|
+
throw new IdentityError('this identity is already connected in another tab or window of this browser; ' +
|
|
936
|
+
'two nodes with one identity evict each other at the anchor (use openSession() to share one ' +
|
|
937
|
+
'node across tabs, or pass lockIdentity: false)');
|
|
938
|
+
}
|
|
939
|
+
return acquired;
|
|
940
|
+
}
|
|
867
941
|
/**
|
|
868
942
|
* Build exactly the options object `LeafNode.connect` reads, credential
|
|
869
943
|
* resolved, origin defaulted, custodial identity validated.
|
|
@@ -1001,6 +1075,7 @@ export function parseRetryReport(json) {
|
|
|
1001
1075
|
armed: raw.armed === true,
|
|
1002
1076
|
online: String(raw.online ?? '0'),
|
|
1003
1077
|
iceFailed: String(raw.iceFailed ?? '0'),
|
|
1078
|
+
channelClosed: String(raw.channelClosed ?? '0'),
|
|
1004
1079
|
triggers: String(raw.triggers ?? '0'),
|
|
1005
1080
|
started: String(raw.started ?? '0'),
|
|
1006
1081
|
coalesced: String(raw.coalesced ?? '0'),
|
package/dist/store/host.js
CHANGED
|
@@ -64,16 +64,22 @@ export const FAREWELL_DEADLINE_MS = 500;
|
|
|
64
64
|
export function peerHexOf(peer) {
|
|
65
65
|
const decimal = /^\d+$/.test(peer);
|
|
66
66
|
const hex = /^(0x)?[0-9a-f]{1,16}$/i.test(peer);
|
|
67
|
+
let out;
|
|
67
68
|
try {
|
|
68
69
|
if (decimal)
|
|
69
|
-
|
|
70
|
-
if (hex)
|
|
71
|
-
|
|
70
|
+
out = BigInt(peer).toString(16).padStart(16, '0');
|
|
71
|
+
else if (hex)
|
|
72
|
+
out = BigInt(peer.startsWith('0x') ? peer : `0x${peer}`).toString(16).padStart(16, '0');
|
|
73
|
+
else
|
|
74
|
+
return null;
|
|
72
75
|
}
|
|
73
76
|
catch {
|
|
74
77
|
return null;
|
|
75
78
|
}
|
|
76
|
-
|
|
79
|
+
// A decimal above u64 renders as 17+ hex digits: no node has that id, so
|
|
80
|
+
// it is not one — `kick('18446744073709551616')` used to target nobody
|
|
81
|
+
// without an error (cubic review of PR #1228).
|
|
82
|
+
return out.length === 16 ? out : null;
|
|
77
83
|
}
|
|
78
84
|
/**
|
|
79
85
|
* A node id the CALLER holds, in the same spelling: 16 lowercase hex.
|
package/dist/store/join.js
CHANGED
|
@@ -119,6 +119,14 @@ export function joinStore(options) {
|
|
|
119
119
|
let closed = false;
|
|
120
120
|
let upstream = null;
|
|
121
121
|
let opening = null;
|
|
122
|
+
/**
|
|
123
|
+
* Bumped by `reconnect()`. An open started under an older epoch belongs
|
|
124
|
+
* to the lost session: it never publishes `upstream` and never clears a
|
|
125
|
+
* newer `opening`, so `reconnect()` can start fresh instead of inheriting
|
|
126
|
+
* that open's late rejection — or waiting on one that never settles
|
|
127
|
+
* (code review 2026-10-11, BROWSER-2).
|
|
128
|
+
*/
|
|
129
|
+
let openEpoch = 0;
|
|
122
130
|
const readyWaiters = [];
|
|
123
131
|
/** The transition in flight and its callers (§2). */
|
|
124
132
|
let transition = null;
|
|
@@ -141,6 +149,7 @@ export function joinStore(options) {
|
|
|
141
149
|
// the relay is installed before the direct half is even tried, so
|
|
142
150
|
// the open below is the thing that decides, and its refusal is
|
|
143
151
|
// typed.
|
|
152
|
+
const epoch = openEpoch;
|
|
144
153
|
opening = Promise.resolve(options.transport.connectPeer?.(hostHex))
|
|
145
154
|
.catch(() => undefined)
|
|
146
155
|
.then(() => options.transport.openStream({
|
|
@@ -149,6 +158,16 @@ export function joinStore(options) {
|
|
|
149
158
|
label: streamId,
|
|
150
159
|
}))
|
|
151
160
|
.then(open => {
|
|
161
|
+
if (epoch !== openEpoch) {
|
|
162
|
+
// Superseded by `reconnect()`: reclaim, never publish.
|
|
163
|
+
try {
|
|
164
|
+
open.close();
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// Already gone.
|
|
168
|
+
}
|
|
169
|
+
throw new StoreError('aborted', 'the stream open was superseded by reconnect()');
|
|
170
|
+
}
|
|
152
171
|
opening = null;
|
|
153
172
|
// An open that resolves after `close()` is reclaimed, never
|
|
154
173
|
// published.
|
|
@@ -170,7 +189,8 @@ export function joinStore(options) {
|
|
|
170
189
|
// answered from it with the same stale error until the caller
|
|
171
190
|
// happens to call `reconnect()`. A transient failure is a retry,
|
|
172
191
|
// not a verdict on the peer.
|
|
173
|
-
|
|
192
|
+
if (epoch === openEpoch)
|
|
193
|
+
opening = null;
|
|
174
194
|
throw error;
|
|
175
195
|
});
|
|
176
196
|
return opening;
|
|
@@ -669,9 +689,25 @@ export function joinStore(options) {
|
|
|
669
689
|
reconnect: async () => {
|
|
670
690
|
if (closed)
|
|
671
691
|
throw new StoreError('closed', 'this store handle is closed');
|
|
672
|
-
// The old stream belongs to the lost session
|
|
692
|
+
// The old stream belongs to the lost session: CLOSE it, never just
|
|
693
|
+
// drop it — a dropped LeafStream stays registered with the node and
|
|
694
|
+
// its wasm listener, so every reconnect leaked one (SDK audit
|
|
695
|
+
// 2026-10-11). An open still in flight is detached by the epoch
|
|
696
|
+
// bump: it can no longer overwrite the new stream (it is closed on
|
|
697
|
+
// arrival instead), and this reconnect no longer inherits its
|
|
698
|
+
// rejection or its hang.
|
|
699
|
+
const old = upstream;
|
|
673
700
|
upstream = null;
|
|
701
|
+
openEpoch += 1;
|
|
674
702
|
opening = null;
|
|
703
|
+
if (old !== null) {
|
|
704
|
+
try {
|
|
705
|
+
old.close();
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
// Already gone with its session.
|
|
709
|
+
}
|
|
710
|
+
}
|
|
675
711
|
let requests;
|
|
676
712
|
try {
|
|
677
713
|
requests = replica.reconnect();
|
package/dist/store/owner.d.ts
CHANGED
|
@@ -39,6 +39,15 @@ import { type Hex } from './wire.js';
|
|
|
39
39
|
export declare const MAX_PENDING_ACTIONS = 32;
|
|
40
40
|
/** Handles one owner will hold (brief §2). */
|
|
41
41
|
export declare const MAX_HANDLES = 256;
|
|
42
|
+
/**
|
|
43
|
+
* Handles one authenticated peer may hold at once. Room for siblings and
|
|
44
|
+
* for a reconnect that overlaps its predecessor's lease, but never the
|
|
45
|
+
* whole table: one peer could otherwise take all {@link MAX_HANDLES} and
|
|
46
|
+
* lock every other player out — the lobby's capacity rule counts distinct
|
|
47
|
+
* peers, so a seated player's repeat joins bypassed it (SDK audit
|
|
48
|
+
* 2026-10-11).
|
|
49
|
+
*/
|
|
50
|
+
export declare const MAX_HANDLES_PER_PEER = 16;
|
|
42
51
|
/** How long a handle lives without an accepted authenticated message. */
|
|
43
52
|
export declare const HANDLE_LEASE_MS = 60000;
|
|
44
53
|
/** What the owner needs from its host. */
|
|
@@ -189,6 +198,8 @@ export interface OwnerHandle {
|
|
|
189
198
|
/** The authenticated peer this handle is bound to (§1.3). */
|
|
190
199
|
readonly peer: string;
|
|
191
200
|
readonly audience: readonly string[];
|
|
201
|
+
/** The opaque key the `join` carried; every read decision passes it on. */
|
|
202
|
+
readonly key?: string;
|
|
192
203
|
/** Highest generation allocated for this handle; monotone (§1.7a). */
|
|
193
204
|
readonly generation: number;
|
|
194
205
|
/**
|
package/dist/store/owner.js
CHANGED
|
@@ -41,6 +41,15 @@ import { decimalValue, decodeMessage, encodeMessage, utf8Length, HANDLE_HEX_LENG
|
|
|
41
41
|
export const MAX_PENDING_ACTIONS = 32;
|
|
42
42
|
/** Handles one owner will hold (brief §2). */
|
|
43
43
|
export const MAX_HANDLES = 256;
|
|
44
|
+
/**
|
|
45
|
+
* Handles one authenticated peer may hold at once. Room for siblings and
|
|
46
|
+
* for a reconnect that overlaps its predecessor's lease, but never the
|
|
47
|
+
* whole table: one peer could otherwise take all {@link MAX_HANDLES} and
|
|
48
|
+
* lock every other player out — the lobby's capacity rule counts distinct
|
|
49
|
+
* peers, so a seated player's repeat joins bypassed it (SDK audit
|
|
50
|
+
* 2026-10-11).
|
|
51
|
+
*/
|
|
52
|
+
export const MAX_HANDLES_PER_PEER = 16;
|
|
44
53
|
/** How long a handle lives without an accepted authenticated message. */
|
|
45
54
|
export const HANDLE_LEASE_MS = 60_000;
|
|
46
55
|
/** How many hook runs one drain may perform before it stops (see `drainEvents`). */
|
|
@@ -245,7 +254,7 @@ export class StoreOwner {
|
|
|
245
254
|
// having been checked once at admission. The handle goes with the
|
|
246
255
|
// refusal here because the feed is the grant: leaving it alive would
|
|
247
256
|
// let `alive` renew the lease of a peer the policy now forbids.
|
|
248
|
-
if (!this.permitsRead(handle.peer, handle.audience)) {
|
|
257
|
+
if (!this.permitsRead(handle.peer, handle.audience, handle.key)) {
|
|
249
258
|
this.forget(handle.h, 'refused');
|
|
250
259
|
// `closed`, the wire's legal unsolicited refusal (§1.12 admits
|
|
251
260
|
// exactly `closed` and `owner-lost` without a `q`): it closes
|
|
@@ -505,7 +514,7 @@ export class StoreOwner {
|
|
|
505
514
|
// must not be served because the handle is still warm. The
|
|
506
515
|
// handle survives the refusal: the request was refused, not
|
|
507
516
|
// the subscription.
|
|
508
|
-
if (!this.permitsRead(peer, bound.audience)) {
|
|
517
|
+
if (!this.permitsRead(peer, bound.audience, bound.key)) {
|
|
509
518
|
return this.refuse('resync-forbidden', [this.no(peer, bound.h, 'forbidden', message.q)]);
|
|
510
519
|
}
|
|
511
520
|
const renewed = { ...bound, lastSeen: now };
|
|
@@ -532,7 +541,7 @@ export class StoreOwner {
|
|
|
532
541
|
// `audience-too-many` at rung 6 (wire.ts, witnessed in
|
|
533
542
|
// wire.test.ts), so a frame carrying more than the bound never
|
|
534
543
|
// reaches dispatch. Re-checking it would be a claim.
|
|
535
|
-
if (!this.permitsRead(peer, message.aud)) {
|
|
544
|
+
if (!this.permitsRead(peer, message.aud, bound.key)) {
|
|
536
545
|
return this.refuse(`${message.k}-forbidden`, [this.no(peer, bound.h, 'forbidden', message.q)]);
|
|
537
546
|
}
|
|
538
547
|
// The audience is rebound BEFORE the projection, so the
|
|
@@ -1000,7 +1009,7 @@ export class StoreOwner {
|
|
|
1000
1009
|
reauthorize() {
|
|
1001
1010
|
const out = [];
|
|
1002
1011
|
for (const handle of [...this.handles.values()]) {
|
|
1003
|
-
if (this.permitsRead(handle.peer, handle.audience))
|
|
1012
|
+
if (this.permitsRead(handle.peer, handle.audience, handle.key))
|
|
1004
1013
|
continue;
|
|
1005
1014
|
this.forget(handle.h, 'refused');
|
|
1006
1015
|
out.push(this.no(handle.peer, handle.h, 'closed', null));
|
|
@@ -1152,9 +1161,16 @@ export class StoreOwner {
|
|
|
1152
1161
|
if (this.handles.size >= MAX_HANDLES) {
|
|
1153
1162
|
return this.refuse('join-capacity', [this.no(peer, null, 'capacity', message.q)]);
|
|
1154
1163
|
}
|
|
1164
|
+
let held = 0;
|
|
1165
|
+
for (const existing of this.handles.values())
|
|
1166
|
+
if (existing.peer === peer)
|
|
1167
|
+
held += 1;
|
|
1168
|
+
if (held >= MAX_HANDLES_PER_PEER) {
|
|
1169
|
+
return this.refuse('join-capacity', [this.no(peer, null, 'capacity', message.q)]);
|
|
1170
|
+
}
|
|
1155
1171
|
// `authorize` receives the AUTHENTICATED peer and the audience it
|
|
1156
1172
|
// asked to read. A refusal is `forbidden` and allocates nothing.
|
|
1157
|
-
if (!this.permitsRead(peer, message.aud)) {
|
|
1173
|
+
if (!this.permitsRead(peer, message.aud, message.key)) {
|
|
1158
1174
|
return this.refuse('join-forbidden', [this.no(peer, null, 'forbidden', message.q)]);
|
|
1159
1175
|
}
|
|
1160
1176
|
const h = this.newHandle();
|
|
@@ -1166,6 +1182,7 @@ export class StoreOwner {
|
|
|
1166
1182
|
inc: this.incarnation,
|
|
1167
1183
|
peer,
|
|
1168
1184
|
audience: [...message.aud],
|
|
1185
|
+
key: message.key,
|
|
1169
1186
|
interest: message.int === undefined ? null : [...message.int],
|
|
1170
1187
|
generation: 0,
|
|
1171
1188
|
revision: this.core.revision,
|
|
@@ -1233,8 +1250,11 @@ export class StoreOwner {
|
|
|
1233
1250
|
*
|
|
1234
1251
|
* A throwing policy is a refusal, never an admission.
|
|
1235
1252
|
*/
|
|
1236
|
-
permitsRead(peer, audience) {
|
|
1237
|
-
|
|
1253
|
+
permitsRead(peer, audience, key) {
|
|
1254
|
+
// `key` is the join's opaque key, which `joinStore({ key })` documents
|
|
1255
|
+
// as read by the host's policy; it was dropped here, so a key-gated
|
|
1256
|
+
// room refused everyone (SDK audit 2026-10-11).
|
|
1257
|
+
const request = (key === undefined ? { type: 'read', peer, audience: [...audience] } : { type: 'read', peer, audience: [...audience], key });
|
|
1238
1258
|
try {
|
|
1239
1259
|
return this.deps.authorize(request) === true;
|
|
1240
1260
|
}
|
package/dist/store/types.d.ts
CHANGED
|
@@ -159,6 +159,12 @@ export type AccessRequest<A extends ActionSpec, I extends InputSpec> = {
|
|
|
159
159
|
readonly type: 'read';
|
|
160
160
|
readonly peer: string;
|
|
161
161
|
readonly audience: readonly string[];
|
|
162
|
+
/**
|
|
163
|
+
* The opaque key the replica joined with (`joinStore({ key })`),
|
|
164
|
+
* carried on every later read decision for that subscription.
|
|
165
|
+
* Absent for the host's own player, which joins no wire.
|
|
166
|
+
*/
|
|
167
|
+
readonly key?: string;
|
|
162
168
|
} | {
|
|
163
169
|
[K in keyof A]: {
|
|
164
170
|
readonly type: 'action';
|
package/dist/stream.d.ts
CHANGED
|
@@ -97,13 +97,24 @@ export interface OpenStreamOptions {
|
|
|
97
97
|
*/
|
|
98
98
|
lossy?: boolean;
|
|
99
99
|
}
|
|
100
|
+
/** Most frames a {@link LeafStream} buffers before its first consumer. */
|
|
101
|
+
export declare const LEAF_STREAM_PENDING_MAX_FRAMES = 1024;
|
|
102
|
+
/** Most bytes a {@link LeafStream} buffers before its first consumer. */
|
|
103
|
+
export declare const LEAF_STREAM_PENDING_MAX_BYTES: number;
|
|
100
104
|
/**
|
|
101
105
|
* One open stream.
|
|
102
106
|
*
|
|
103
107
|
* Inbound payloads are delivered to `for await` consumers and to
|
|
104
108
|
* {@link LeafStream.onMessage} listeners. Payloads that arrive before
|
|
105
109
|
* anyone consumes them are buffered in arrival order, so a page that
|
|
106
|
-
* opens a stream and awaits it a tick later loses nothing
|
|
110
|
+
* opens a stream and awaits it a tick later loses nothing — up to
|
|
111
|
+
* {@link LEAF_STREAM_PENDING_MAX_FRAMES} frames and
|
|
112
|
+
* {@link LEAF_STREAM_PENDING_MAX_BYTES} bytes. Past either bound the
|
|
113
|
+
* OLDEST buffered frame is dropped and counted in
|
|
114
|
+
* {@link LeafStream.droppedBeforeConsumer} (a single frame larger than the
|
|
115
|
+
* byte bound is never buffered, and is counted there too): a stream used only to send
|
|
116
|
+
* (the store, netcode and region-link layers read inbound traffic
|
|
117
|
+
* elsewhere) must not keep every frame the peer sends for its lifetime.
|
|
107
118
|
*/
|
|
108
119
|
export declare class LeafStream implements AsyncIterable<Uint8Array> {
|
|
109
120
|
private readonly inner;
|
|
@@ -111,7 +122,11 @@ export declare class LeafStream implements AsyncIterable<Uint8Array> {
|
|
|
111
122
|
private readonly queues;
|
|
112
123
|
private readonly listeners;
|
|
113
124
|
private readonly pending;
|
|
125
|
+
private pendingBytes;
|
|
126
|
+
private dropped;
|
|
114
127
|
private closed;
|
|
128
|
+
/** Why the SESSION closed this stream, when it did so for a leader change. */
|
|
129
|
+
private retiredBy;
|
|
115
130
|
/**
|
|
116
131
|
* This stream's wire id as a number, or `null` when the inner
|
|
117
132
|
* object's `stream_id_hex()` was not hex.
|
|
@@ -144,6 +159,11 @@ export declare class LeafStream implements AsyncIterable<Uint8Array> {
|
|
|
144
159
|
* either would fail the id comparison too, loudly.
|
|
145
160
|
*/
|
|
146
161
|
private readonly peerId;
|
|
162
|
+
/**
|
|
163
|
+
* Frames dropped from the pre-consumer buffer because no `for await`
|
|
164
|
+
* consumer or `onMessage` listener was attached and it was full.
|
|
165
|
+
*/
|
|
166
|
+
get droppedBeforeConsumer(): number;
|
|
147
167
|
/** The authenticated peer this stream is with, 16 lowercase hex. */
|
|
148
168
|
get peerNode(): string;
|
|
149
169
|
/**
|
|
@@ -169,6 +189,14 @@ export declare class LeafStream implements AsyncIterable<Uint8Array> {
|
|
|
169
189
|
* taken as an already-decoded payload, which is what a
|
|
170
190
|
* host-supplied {@link LeafWasmStreamLike} may deliver.
|
|
171
191
|
*/
|
|
192
|
+
/**
|
|
193
|
+
* Hand the pre-consumer buffer over, emptying it — count included. The
|
|
194
|
+
* byte count stayed at the drained total, so after a consumer drained the
|
|
195
|
+
* buffer and detached, rebuffering tripped the byte cap almost at once
|
|
196
|
+
* and evicted all but the newest frame on nearly every arrival (code
|
|
197
|
+
* review 2026-10-11, BROWSER-1).
|
|
198
|
+
*/
|
|
199
|
+
private drainPending;
|
|
172
200
|
private receive;
|
|
173
201
|
private decode;
|
|
174
202
|
}
|