@net-mesh/browser 0.41.1 → 0.43.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.
@@ -57,7 +57,10 @@ export interface SessionOptions extends ConnectOptions {
57
57
  dbName?: string;
58
58
  /**
59
59
  * Override the Web Lock and `BroadcastChannel` name. Defaults to
60
- * `net-mesh/<origin>/<identity-fingerprint>`.
60
+ * `net-mesh/<origin>/<identity-fingerprint>`, or — with a custodial
61
+ * `entitySecretHex` — to the identity lock `connect()` takes for that
62
+ * secret, so a `connect()` tab and an `openSession()` tab cannot both run
63
+ * the identity. Setting it opts out of that contention.
61
64
  *
62
65
  * For tests that need two independent elections inside one origin.
63
66
  * A page should not set it: two scopes on one identity is two nodes
@@ -65,7 +68,6 @@ export interface SessionOptions extends ConnectOptions {
65
68
  */
66
69
  lockScope?: string;
67
70
  }
68
- /** The origin's Net node, as this tab sees it. */
69
71
  export declare class MeshSession {
70
72
  private readonly inner;
71
73
  /**
@@ -28,7 +28,7 @@
28
28
  */
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
- import { buildConnectRequest, parseAttemptStatus, parseCounters, parseDescriptors, } from '../node.js';
31
+ import { buildConnectRequest, identityLockName, parseAttemptStatus, parseCounters, parseDescriptors, } from '../node.js';
32
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';
@@ -36,6 +36,13 @@ import { isLifecycleEvent, parseSessionEvent, } from './events.js';
36
36
  import { asSessionModule } from './wasm.js';
37
37
  import { EventHub } from '../events.js';
38
38
  /** The origin's Net node, as this tab sees it. */
39
+ /**
40
+ * What a send on a stream ended by a leader change fails with. Worded
41
+ * the way the leaf words a stale handle, because `isStaleStream` (the
42
+ * store's reopen test) matches exactly that: a store over a session
43
+ * reopens on the new leader rather than counting `send-failed` forever.
44
+ */
45
+ const STALE_AFTER_HANDOFF = 'stale stream handle: the leader changed; reopen the stream';
39
46
  export class MeshSession {
40
47
  inner;
41
48
  /**
@@ -100,7 +107,7 @@ export class MeshSession {
100
107
  // The generation that owned the calls is the one they were
101
108
  // issued under — typed `LeaderLost`, never resumed.
102
109
  this.orgHandles.retireAll(new OrgLeaderLostError(this.streamGeneration));
103
- this.endStreams();
110
+ this.endStreams(STALE_AFTER_HANDOFF);
104
111
  return;
105
112
  }
106
113
  // And the abrupt case: a generation that moved under us. Read
@@ -114,7 +121,7 @@ export class MeshSession {
114
121
  const previous = this.streamGeneration;
115
122
  this.streamGeneration = current;
116
123
  this.orgHandles.retireAll(new OrgLeaderLostError(previous));
117
- this.endStreams();
124
+ this.endStreams(STALE_AFTER_HANDOFF);
118
125
  });
119
126
  }
120
127
  /** `'leader'` if this tab runs the node, `'follower'` if another does. */
@@ -725,11 +732,17 @@ export class MeshSession {
725
732
  * on close; idempotent, because a page may have closed a stream
726
733
  * itself.
727
734
  */
728
- endStreams() {
735
+ endStreams(staleReason) {
729
736
  const open = [...this.streams];
730
737
  this.streams.clear();
731
- for (const stream of open)
732
- stream.close();
738
+ for (const stream of open) {
739
+ // A leader change retires with the reopenable wording; closing the
740
+ // session is permanent.
741
+ if (staleReason === undefined)
742
+ stream.close();
743
+ else
744
+ stream.retire(staleReason);
745
+ }
733
746
  }
734
747
  /**
735
748
  * Re-type whatever the boundary threw.
@@ -777,6 +790,17 @@ export async function openSession(options) {
777
790
  ...(options.dbName === undefined ? {} : { dbName: options.dbName }),
778
791
  ...(options.lockScope === undefined ? {} : { lockScope: options.lockScope }),
779
792
  };
793
+ // A custodial identity elects under the same lock name `connect()` takes
794
+ // for it, so mixed connect()/openSession() tabs with one secret cannot
795
+ // both run a node (cubic review of PR #1228). The scope also names the
796
+ // `BroadcastChannel`, and every session tab with that secret derives the
797
+ // same name, so the leader and its followers still meet.
798
+ // `lockIdentity: false` keeps the leaf's own default scope.
799
+ if (request.lockScope === undefined && request.entitySecretHex !== undefined && options.lockIdentity !== false) {
800
+ const shared = await identityLockName(request.entitySecretHex);
801
+ if (shared !== null)
802
+ request.lockScope = shared;
803
+ }
780
804
  try {
781
805
  return new MeshSession(await module.MeshSession.open(request));
782
806
  }
package/dist/lobby.js CHANGED
@@ -46,7 +46,7 @@
46
46
  * tags the node should keep announcing as `tags`.
47
47
  */
48
48
  import { StoreError } from './store/errors.js';
49
- import { hostInternals, hostStore, } from './store/host.js';
49
+ import { hostInternals, hostStore, nodeHexOf, } from './store/host.js';
50
50
  import { joinStore } from './store/join.js';
51
51
  import { hostPlayer } from './store/player.js';
52
52
  export class LobbyError extends Error {
@@ -215,10 +215,14 @@ function decodeRecord(game, tag) {
215
215
  return null;
216
216
  }
217
217
  }
218
+ // Read the id the way every other caller-held id is read (`nodeHexOf`):
219
+ // sixteen hex digits are hex, and anything shorter may be the exact-decimal
220
+ // `peerNode` an event handed the page. Reading every digit run as hex kicked
221
+ // a DIFFERENT id and removed nobody (SDK audit 2026-10-11).
218
222
  function hexPeer(peer) {
219
- const raw = peer.startsWith('0x') ? peer.slice(2) : peer;
220
- if (/^[0-9a-fA-F]{1,16}$/.test(raw))
221
- return raw.toLowerCase().padStart(16, '0');
223
+ const hex = nodeHexOf(peer);
224
+ if (hex !== null)
225
+ return hex;
222
226
  throw new LobbyError('invalid', `not a peer id: ${JSON.stringify(peer)}`);
223
227
  }
224
228
  /**
@@ -1453,16 +1453,16 @@ export interface InitOutput {
1453
1453
  readonly proxystream_send: (a: number, b: number) => number;
1454
1454
  readonly proxystream_stream_id_hex: (a: number, b: number) => void;
1455
1455
  readonly start: () => void;
1456
- readonly __wasm_bindgen_func_elem_6504: (a: number, b: number, c: number, d: number) => void;
1457
- readonly __wasm_bindgen_func_elem_6506: (a: number, b: number, c: number, d: number) => void;
1458
- readonly __wasm_bindgen_func_elem_2365: (a: number, b: number, c: number) => number;
1459
- readonly __wasm_bindgen_func_elem_2366: (a: number, b: number, c: number) => void;
1460
- readonly __wasm_bindgen_func_elem_2366_112: (a: number, b: number, c: number) => void;
1461
- readonly __wasm_bindgen_func_elem_2366_113: (a: number, b: number, c: number) => void;
1462
- readonly __wasm_bindgen_func_elem_2366_114: (a: number, b: number, c: number) => void;
1463
- readonly __wasm_bindgen_func_elem_2366_115: (a: number, b: number, c: number) => void;
1464
- readonly __wasm_bindgen_func_elem_2366_116: (a: number, b: number, c: number) => void;
1465
- readonly __wasm_bindgen_func_elem_2366_117: (a: number, b: number, c: number) => void;
1456
+ readonly __wasm_bindgen_func_elem_6508: (a: number, b: number, c: number, d: number) => void;
1457
+ readonly __wasm_bindgen_func_elem_6510: (a: number, b: number, c: number, d: number) => void;
1458
+ readonly __wasm_bindgen_func_elem_2366: (a: number, b: number, c: number) => number;
1459
+ readonly __wasm_bindgen_func_elem_2367: (a: number, b: number, c: number) => void;
1460
+ readonly __wasm_bindgen_func_elem_2367_112: (a: number, b: number, c: number) => void;
1461
+ readonly __wasm_bindgen_func_elem_2367_113: (a: number, b: number, c: number) => void;
1462
+ readonly __wasm_bindgen_func_elem_2367_114: (a: number, b: number, c: number) => void;
1463
+ readonly __wasm_bindgen_func_elem_2367_115: (a: number, b: number, c: number) => void;
1464
+ readonly __wasm_bindgen_func_elem_2367_116: (a: number, b: number, c: number) => void;
1465
+ readonly __wasm_bindgen_func_elem_2367_117: (a: number, b: number, c: number) => void;
1466
1466
  readonly __wbindgen_export: (a: number, b: number) => number;
1467
1467
  readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
1468
1468
  readonly __wbindgen_export3: (a: number) => void;
package/dist/net_leaf.js CHANGED
@@ -2718,7 +2718,7 @@ function __wbg_get_imports() {
2718
2718
  const ret = getObject(arg0).error;
2719
2719
  return isLikeNone(ret) ? 0 : addHeapObject(ret);
2720
2720
  },
2721
- __wbg_error_4c50581549efd2db: function(arg0, arg1) {
2721
+ __wbg_error_5b007554bf198f44: function(arg0, arg1) {
2722
2722
  console.error(getStringFromWasm0(arg0, arg1));
2723
2723
  },
2724
2724
  __wbg_error_6a23b57453026a47: function() { return handleError(function (arg0) {
@@ -2914,7 +2914,7 @@ function __wbg_get_imports() {
2914
2914
  const a = state0.a;
2915
2915
  state0.a = 0;
2916
2916
  try {
2917
- return __wasm_bindgen_func_elem_6506(a, state0.b, arg0, arg1);
2917
+ return __wasm_bindgen_func_elem_6510(a, state0.b, arg0, arg1);
2918
2918
  } finally {
2919
2919
  state0.a = a;
2920
2920
  }
@@ -2956,7 +2956,7 @@ function __wbg_get_imports() {
2956
2956
  const a = state0.a;
2957
2957
  state0.a = 0;
2958
2958
  try {
2959
- return __wasm_bindgen_func_elem_6506(a, state0.b, arg0, arg1);
2959
+ return __wasm_bindgen_func_elem_6510(a, state0.b, arg0, arg1);
2960
2960
  } finally {
2961
2961
  state0.a = a;
2962
2962
  }
@@ -3266,47 +3266,47 @@ function __wbg_get_imports() {
3266
3266
  },
3267
3267
  __wbindgen_generic_0000000000000001: function(arg0, arg1) {
3268
3268
  // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 34, ret: Externref, inner_ret: Some(Externref) }, mutable: true }) -> Externref`.
3269
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2365);
3269
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2366);
3270
3270
  return addHeapObject(ret);
3271
3271
  },
3272
3272
  __wbindgen_generic_0000000000000002: function(arg0, arg1) {
3273
3273
  // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 35, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3274
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2366);
3274
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2367);
3275
3275
  return addHeapObject(ret);
3276
3276
  },
3277
3277
  __wbindgen_generic_0000000000000003: function(arg0, arg1) {
3278
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
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_6504);
3279
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_6508);
3280
3280
  return addHeapObject(ret);
3281
3281
  },
3282
3282
  __wbindgen_generic_0000000000000004: function(arg0, arg1) {
3283
3283
  // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("CloseEvent")], shim_idx: 35, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3284
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2366_112);
3284
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2367_112);
3285
3285
  return addHeapObject(ret);
3286
3286
  },
3287
3287
  __wbindgen_generic_0000000000000005: function(arg0, arg1) {
3288
3288
  // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 35, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3289
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2366_113);
3289
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2367_113);
3290
3290
  return addHeapObject(ret);
3291
3291
  },
3292
3292
  __wbindgen_generic_0000000000000006: function(arg0, arg1) {
3293
3293
  // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 35, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3294
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2366_114);
3294
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2367_114);
3295
3295
  return addHeapObject(ret);
3296
3296
  },
3297
3297
  __wbindgen_generic_0000000000000007: function(arg0, arg1) {
3298
3298
  // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("RTCDataChannelEvent")], shim_idx: 35, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3299
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2366_115);
3299
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2367_115);
3300
3300
  return addHeapObject(ret);
3301
3301
  },
3302
3302
  __wbindgen_generic_0000000000000008: function(arg0, arg1) {
3303
3303
  // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("RTCPeerConnectionIceErrorEvent")], shim_idx: 35, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3304
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2366_116);
3304
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2367_116);
3305
3305
  return addHeapObject(ret);
3306
3306
  },
3307
3307
  __wbindgen_generic_0000000000000009: function(arg0, arg1) {
3308
3308
  // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("RTCPeerConnectionIceEvent")], shim_idx: 35, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
3309
- const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2366_117);
3309
+ const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2367_117);
3310
3310
  return addHeapObject(ret);
3311
3311
  },
3312
3312
  __wbindgen_generic_000000000000000a: function(arg0) {
@@ -3333,47 +3333,47 @@ function __wbg_get_imports() {
3333
3333
  };
3334
3334
  }
3335
3335
 
3336
- function __wasm_bindgen_func_elem_2366(arg0, arg1, arg2) {
3337
- wasm.__wasm_bindgen_func_elem_2366(arg0, arg1, addHeapObject(arg2));
3336
+ function __wasm_bindgen_func_elem_2367(arg0, arg1, arg2) {
3337
+ wasm.__wasm_bindgen_func_elem_2367(arg0, arg1, addHeapObject(arg2));
3338
3338
  }
3339
3339
 
3340
- function __wasm_bindgen_func_elem_2366_112(arg0, arg1, arg2) {
3341
- wasm.__wasm_bindgen_func_elem_2366_112(arg0, arg1, addHeapObject(arg2));
3340
+ function __wasm_bindgen_func_elem_2367_112(arg0, arg1, arg2) {
3341
+ wasm.__wasm_bindgen_func_elem_2367_112(arg0, arg1, addHeapObject(arg2));
3342
3342
  }
3343
3343
 
3344
- function __wasm_bindgen_func_elem_2366_113(arg0, arg1, arg2) {
3345
- wasm.__wasm_bindgen_func_elem_2366_113(arg0, arg1, addHeapObject(arg2));
3344
+ function __wasm_bindgen_func_elem_2367_113(arg0, arg1, arg2) {
3345
+ wasm.__wasm_bindgen_func_elem_2367_113(arg0, arg1, addHeapObject(arg2));
3346
3346
  }
3347
3347
 
3348
- function __wasm_bindgen_func_elem_2366_114(arg0, arg1, arg2) {
3349
- wasm.__wasm_bindgen_func_elem_2366_114(arg0, arg1, addHeapObject(arg2));
3348
+ function __wasm_bindgen_func_elem_2367_114(arg0, arg1, arg2) {
3349
+ wasm.__wasm_bindgen_func_elem_2367_114(arg0, arg1, addHeapObject(arg2));
3350
3350
  }
3351
3351
 
3352
- function __wasm_bindgen_func_elem_2366_115(arg0, arg1, arg2) {
3353
- wasm.__wasm_bindgen_func_elem_2366_115(arg0, arg1, addHeapObject(arg2));
3352
+ function __wasm_bindgen_func_elem_2367_115(arg0, arg1, arg2) {
3353
+ wasm.__wasm_bindgen_func_elem_2367_115(arg0, arg1, addHeapObject(arg2));
3354
3354
  }
3355
3355
 
3356
- function __wasm_bindgen_func_elem_2366_116(arg0, arg1, arg2) {
3357
- wasm.__wasm_bindgen_func_elem_2366_116(arg0, arg1, addHeapObject(arg2));
3356
+ function __wasm_bindgen_func_elem_2367_116(arg0, arg1, arg2) {
3357
+ wasm.__wasm_bindgen_func_elem_2367_116(arg0, arg1, addHeapObject(arg2));
3358
3358
  }
3359
3359
 
3360
- function __wasm_bindgen_func_elem_2366_117(arg0, arg1, arg2) {
3361
- wasm.__wasm_bindgen_func_elem_2366_117(arg0, arg1, addHeapObject(arg2));
3360
+ function __wasm_bindgen_func_elem_2367_117(arg0, arg1, arg2) {
3361
+ wasm.__wasm_bindgen_func_elem_2367_117(arg0, arg1, addHeapObject(arg2));
3362
3362
  }
3363
3363
 
3364
- function __wasm_bindgen_func_elem_2365(arg0, arg1, arg2) {
3365
- const ret = wasm.__wasm_bindgen_func_elem_2365(arg0, arg1, addHeapObject(arg2));
3364
+ function __wasm_bindgen_func_elem_2366(arg0, arg1, arg2) {
3365
+ const ret = wasm.__wasm_bindgen_func_elem_2366(arg0, arg1, addHeapObject(arg2));
3366
3366
  return takeObject(ret);
3367
3367
  }
3368
3368
 
3369
- function __wasm_bindgen_func_elem_6506(arg0, arg1, arg2, arg3) {
3370
- wasm.__wasm_bindgen_func_elem_6506(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3));
3369
+ function __wasm_bindgen_func_elem_6510(arg0, arg1, arg2, arg3) {
3370
+ wasm.__wasm_bindgen_func_elem_6510(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3));
3371
3371
  }
3372
3372
 
3373
- function __wasm_bindgen_func_elem_6504(arg0, arg1, arg2) {
3373
+ function __wasm_bindgen_func_elem_6508(arg0, arg1, arg2) {
3374
3374
  try {
3375
3375
  const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
3376
- wasm.__wasm_bindgen_func_elem_6504(retptr, arg0, arg1, addHeapObject(arg2));
3376
+ wasm.__wasm_bindgen_func_elem_6508(retptr, arg0, arg1, addHeapObject(arg2));
3377
3377
  var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
3378
3378
  var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
3379
3379
  if (r1) {
Binary file
@@ -119,16 +119,16 @@ export const proxystream_peer_node_hex: (a: number, b: number) => void;
119
119
  export const proxystream_send: (a: number, b: number) => number;
120
120
  export const proxystream_stream_id_hex: (a: number, b: number) => void;
121
121
  export const start: () => void;
122
- export const __wasm_bindgen_func_elem_6504: (a: number, b: number, c: number, d: number) => void;
123
- export const __wasm_bindgen_func_elem_6506: (a: number, b: number, c: number, d: number) => void;
124
- export const __wasm_bindgen_func_elem_2365: (a: number, b: number, c: number) => number;
125
- export const __wasm_bindgen_func_elem_2366: (a: number, b: number, c: number) => void;
126
- export const __wasm_bindgen_func_elem_2366_112: (a: number, b: number, c: number) => void;
127
- export const __wasm_bindgen_func_elem_2366_113: (a: number, b: number, c: number) => void;
128
- export const __wasm_bindgen_func_elem_2366_114: (a: number, b: number, c: number) => void;
129
- export const __wasm_bindgen_func_elem_2366_115: (a: number, b: number, c: number) => void;
130
- export const __wasm_bindgen_func_elem_2366_116: (a: number, b: number, c: number) => void;
131
- export const __wasm_bindgen_func_elem_2366_117: (a: number, b: number, c: number) => void;
122
+ export const __wasm_bindgen_func_elem_6508: (a: number, b: number, c: number, d: number) => void;
123
+ export const __wasm_bindgen_func_elem_6510: (a: number, b: number, c: number, d: number) => void;
124
+ export const __wasm_bindgen_func_elem_2366: (a: number, b: number, c: number) => number;
125
+ export const __wasm_bindgen_func_elem_2367: (a: number, b: number, c: number) => void;
126
+ export const __wasm_bindgen_func_elem_2367_112: (a: number, b: number, c: number) => void;
127
+ export const __wasm_bindgen_func_elem_2367_113: (a: number, b: number, c: number) => void;
128
+ export const __wasm_bindgen_func_elem_2367_114: (a: number, b: number, c: number) => void;
129
+ export const __wasm_bindgen_func_elem_2367_115: (a: number, b: number, c: number) => void;
130
+ export const __wasm_bindgen_func_elem_2367_116: (a: number, b: number, c: number) => void;
131
+ export const __wasm_bindgen_func_elem_2367_117: (a: number, b: number, c: number) => void;
132
132
  export const __wbindgen_export: (a: number, b: number) => number;
133
133
  export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
134
134
  export const __wbindgen_export3: (a: number) => void;
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. */
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.
@@ -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
  /**