@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +12 -0
  3. package/dist/index.bundle.js +2 -2
  4. package/dist/leader/session.d.ts +4 -2
  5. package/dist/leader/session.js +30 -6
  6. package/dist/lobby.d.ts +29 -2
  7. package/dist/lobby.js +80 -10
  8. package/dist/net_leaf.d.ts +19 -21
  9. package/dist/net_leaf.js +42 -44
  10. package/dist/net_leaf_bg.wasm +0 -0
  11. package/dist/net_leaf_bg.wasm.d.ts +10 -10
  12. package/dist/netcode/assignment.d.ts +169 -0
  13. package/dist/netcode/assignment.js +321 -0
  14. package/dist/netcode/client.d.ts +125 -3
  15. package/dist/netcode/client.js +291 -27
  16. package/dist/netcode/codec.d.ts +180 -0
  17. package/dist/netcode/codec.js +547 -0
  18. package/dist/netcode/host.d.ts +58 -1
  19. package/dist/netcode/host.js +254 -9
  20. package/dist/netcode/index.d.ts +14 -4
  21. package/dist/netcode/index.js +8 -3
  22. package/dist/netcode/interpolate.d.ts +24 -1
  23. package/dist/netcode/interpolate.js +52 -1
  24. package/dist/netcode/paths.d.ts +115 -0
  25. package/dist/netcode/paths.js +261 -0
  26. package/dist/netcode/shards.d.ts +138 -0
  27. package/dist/netcode/shards.js +270 -0
  28. package/dist/netcode/wire.d.ts +37 -3
  29. package/dist/netcode/wire.js +28 -1
  30. package/dist/node.d.ts +20 -0
  31. package/dist/node.js +76 -1
  32. package/dist/store/host.js +10 -4
  33. package/dist/store/join.js +38 -2
  34. package/dist/store/owner.d.ts +11 -0
  35. package/dist/store/owner.js +27 -7
  36. package/dist/store/types.d.ts +6 -0
  37. package/dist/stream.d.ts +29 -1
  38. package/dist/stream.js +66 -4
  39. package/dist/udp-probe.d.ts +21 -3
  40. package/dist/udp-probe.js +78 -7
  41. package/dist/world/link.d.ts +9 -0
  42. package/dist/world/link.js +4 -3
  43. package/dist/world/regions.d.ts +9 -2
  44. package/dist/world/regions.js +34 -4
  45. package/package.json +3 -2
@@ -40,22 +40,43 @@ export interface WireInput<I> {
40
40
  readonly data: I;
41
41
  }
42
42
  export type Frame<E, I> =
43
- /** Player → host: join (repeated until the first snapshot). */
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
- /** Player → host: the newest inputs, the unacknowledged ones repeated. */
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
  /**
@@ -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) ? frame : null;
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
- return new BrowserNode(inner, options.bootstrapUrl ?? null, failureTyping, options.anchorRtcAddr ?? null);
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'),
@@ -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
- return BigInt(peer).toString(16).padStart(16, '0');
70
- if (hex)
71
- return BigInt(peer.startsWith('0x') ? peer : `0x${peer}`).toString(16).padStart(16, '0');
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
- return null;
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.
@@ -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
- opening = null;
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();
@@ -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
  /**
@@ -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
- const request = { type: 'read', peer, audience: [...audience] };
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
  }
@@ -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
  }