@net-mesh/browser 0.37.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 (110) hide show
  1. package/CHANGELOG.md +517 -0
  2. package/LICENSE-APACHE +190 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +666 -0
  5. package/dist/async-queue.d.ts +26 -0
  6. package/dist/async-queue.js +62 -0
  7. package/dist/credential.d.ts +70 -0
  8. package/dist/credential.js +95 -0
  9. package/dist/errors.d.ts +400 -0
  10. package/dist/errors.js +726 -0
  11. package/dist/events.d.ts +232 -0
  12. package/dist/events.js +298 -0
  13. package/dist/identity.d.ts +40 -0
  14. package/dist/identity.js +78 -0
  15. package/dist/index.bundle.js +2 -0
  16. package/dist/index.d.ts +38 -0
  17. package/dist/index.js +46 -0
  18. package/dist/leader/events.d.ts +111 -0
  19. package/dist/leader/events.js +116 -0
  20. package/dist/leader/index.d.ts +14 -0
  21. package/dist/leader/index.js +11 -0
  22. package/dist/leader/session.d.ts +539 -0
  23. package/dist/leader/session.js +770 -0
  24. package/dist/leader/wasm.d.ts +123 -0
  25. package/dist/leader/wasm.js +39 -0
  26. package/dist/lobby.d.ts +183 -0
  27. package/dist/lobby.js +504 -0
  28. package/dist/local.d.ts +80 -0
  29. package/dist/local.js +160 -0
  30. package/dist/net_leaf.d.ts +1494 -0
  31. package/dist/net_leaf.js +3753 -0
  32. package/dist/net_leaf_bg.wasm +0 -0
  33. package/dist/net_leaf_bg.wasm.d.ts +137 -0
  34. package/dist/netcode/client.d.ts +102 -0
  35. package/dist/netcode/client.js +296 -0
  36. package/dist/netcode/clock.d.ts +45 -0
  37. package/dist/netcode/clock.js +54 -0
  38. package/dist/netcode/host.d.ts +118 -0
  39. package/dist/netcode/host.js +276 -0
  40. package/dist/netcode/index.d.ts +19 -0
  41. package/dist/netcode/index.js +14 -0
  42. package/dist/netcode/interpolate.d.ts +50 -0
  43. package/dist/netcode/interpolate.js +100 -0
  44. package/dist/netcode/wire.d.ts +125 -0
  45. package/dist/netcode/wire.js +117 -0
  46. package/dist/node.d.ts +908 -0
  47. package/dist/node.js +1085 -0
  48. package/dist/org.d.ts +412 -0
  49. package/dist/org.js +567 -0
  50. package/dist/peer-driver.d.ts +117 -0
  51. package/dist/peer-driver.js +224 -0
  52. package/dist/store/assembly.d.ts +176 -0
  53. package/dist/store/assembly.js +325 -0
  54. package/dist/store/chunker.d.ts +83 -0
  55. package/dist/store/chunker.js +190 -0
  56. package/dist/store/core.d.ts +136 -0
  57. package/dist/store/core.js +477 -0
  58. package/dist/store/definition.d.ts +23 -0
  59. package/dist/store/definition.js +83 -0
  60. package/dist/store/errors.d.ts +77 -0
  61. package/dist/store/errors.js +94 -0
  62. package/dist/store/host.d.ts +247 -0
  63. package/dist/store/host.js +675 -0
  64. package/dist/store/index.d.ts +50 -0
  65. package/dist/store/index.js +40 -0
  66. package/dist/store/interest.d.ts +40 -0
  67. package/dist/store/interest.js +77 -0
  68. package/dist/store/inventory.d.ts +81 -0
  69. package/dist/store/inventory.js +140 -0
  70. package/dist/store/join.d.ts +118 -0
  71. package/dist/store/join.js +724 -0
  72. package/dist/store/json.d.ts +50 -0
  73. package/dist/store/json.js +283 -0
  74. package/dist/store/ledger.d.ts +187 -0
  75. package/dist/store/ledger.js +292 -0
  76. package/dist/store/owner.d.ts +616 -0
  77. package/dist/store/owner.js +1711 -0
  78. package/dist/store/patch.d.ts +70 -0
  79. package/dist/store/patch.js +166 -0
  80. package/dist/store/player.d.ts +65 -0
  81. package/dist/store/player.js +261 -0
  82. package/dist/store/replica.d.ts +187 -0
  83. package/dist/store/replica.js +740 -0
  84. package/dist/store/state.d.ts +42 -0
  85. package/dist/store/state.js +165 -0
  86. package/dist/store/types.d.ts +200 -0
  87. package/dist/store/types.js +25 -0
  88. package/dist/store/visibility.d.ts +118 -0
  89. package/dist/store/visibility.js +282 -0
  90. package/dist/store/wire.d.ts +309 -0
  91. package/dist/store/wire.js +834 -0
  92. package/dist/stream.d.ts +174 -0
  93. package/dist/stream.js +238 -0
  94. package/dist/three/index.d.ts +109 -0
  95. package/dist/three/index.js +130 -0
  96. package/dist/udp-probe.d.ts +159 -0
  97. package/dist/udp-probe.js +245 -0
  98. package/dist/wasm.d.ts +642 -0
  99. package/dist/wasm.js +98 -0
  100. package/dist/world/border.d.ts +84 -0
  101. package/dist/world/border.js +99 -0
  102. package/dist/world/handoff.d.ts +187 -0
  103. package/dist/world/handoff.js +222 -0
  104. package/dist/world/index.d.ts +13 -0
  105. package/dist/world/index.js +9 -0
  106. package/dist/world/link.d.ts +179 -0
  107. package/dist/world/link.js +476 -0
  108. package/dist/world/regions.d.ts +154 -0
  109. package/dist/world/regions.js +314 -0
  110. package/package.json +72 -0
@@ -0,0 +1,675 @@
1
+ /**
2
+ * Slice G — `hostStore` and `joinStore` over a real session.
3
+ *
4
+ * This is the layer where the authenticated-origin gate is either
5
+ * satisfied or not, and it is three lines of it: a frame arrives as a
6
+ * `stream_data` event, the event carries `peerNode` — the peer whose
7
+ * installed session opened the packet — and that string is what goes
8
+ * into `owner.receive(frame, peer)`. Nothing in a store frame is
9
+ * consulted for identity, because no store frame has an originator
10
+ * field to consult (§1.3).
11
+ *
12
+ * ## What this module is typed against
13
+ *
14
+ * Not a port, not an adapter: the structural subset of the real
15
+ * `BrowserNode`/`MeshSession` surface that the store actually uses.
16
+ * Both satisfy {@link StoreTransport} as they are, so there is no
17
+ * second implementation of anything, and a test can drive the same
18
+ * type without a mesh.
19
+ *
20
+ * ## The gate this slice does NOT clear
21
+ *
22
+ * §4 gates G on leader-proxy lifecycle: follower tabs, last-consumer
23
+ * cleanup, the peer/stream lifecycle on a proxied handle. A
24
+ * `ProxyStream` now carries its peer (6256f970b) which is the
25
+ * precondition, but the *store's* use of it on a follower is not
26
+ * established, and nothing here claims it is.
27
+ */
28
+ import { isStaleStream, StoreError } from './errors.js';
29
+ import { StoreOwner } from './owner.js';
30
+ import { encodeMessage } from './wire.js';
31
+ import { applyVisibility, compileVisibility } from './visibility.js';
32
+ /** How often a host expires handles whose lease has run out (§2). */
33
+ export const HOST_SWEEP_MS = 5_000;
34
+ /**
35
+ * How long `close()` will wait for one goodbye to flush.
36
+ *
37
+ * A page tearing down cannot wait forever to be polite, and a
38
+ * transport whose `send` never settles would otherwise make `close()`
39
+ * never return — a review probe held one and measured exactly that.
40
+ * Short, because the alternative to a bounded wait is a hang, and the
41
+ * frame is not retried afterwards: a farewell that missed its window
42
+ * is a counted loss.
43
+ *
44
+ * HALF A SECOND, not two: the first version chose two and a review
45
+ * probe — which allowed 750 ms for `close()` to return — read that as
46
+ * an unbounded close, and it was right to. A page tearing down has an
47
+ * unload budget under a second, and a frame that has not flushed on a
48
+ * live DataChannel in 500 ms is not going to matter to the replica
49
+ * that was going to receive it.
50
+ */
51
+ export const FAREWELL_DEADLINE_MS = 500;
52
+ /**
53
+ * A peer id in the spelling `openStream` takes: 16 lowercase hex.
54
+ *
55
+ * Events carry the authenticated peer as an EXACT DECIMAL string, and
56
+ * `openStream({ peer })` requires 16 hex digits. Handing the decimal
57
+ * straight back is rejected for a short id and — worse — names a
58
+ * DIFFERENT peer for a 16-digit decimal one, which is a cross-peer
59
+ * defect wearing the shape of a formatting bug. `LeafStream` already
60
+ * reconciles the two spellings through `BigInt`; this is the same
61
+ * reconciliation for the store's own seam.
62
+ */
63
+ export function peerHexOf(peer) {
64
+ const decimal = /^\d+$/.test(peer);
65
+ const hex = /^(0x)?[0-9a-f]{1,16}$/i.test(peer);
66
+ try {
67
+ if (decimal)
68
+ return BigInt(peer).toString(16).padStart(16, '0');
69
+ if (hex)
70
+ return BigInt(peer.startsWith('0x') ? peer : `0x${peer}`).toString(16).padStart(16, '0');
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ return null;
76
+ }
77
+ /** Whether two peer spellings name the same node. */
78
+ export function samePeer(left, right) {
79
+ const a = peerHexOf(left);
80
+ const b = peerHexOf(right);
81
+ return a !== null && a === b;
82
+ }
83
+ /**
84
+ * The store addresses live on each transport.
85
+ *
86
+ * A node may host several stores of one definition, and a joiner
87
+ * names the one it wants (`join.store`). Two stores answering to the
88
+ * SAME name on one transport make that name ambiguous: both would
89
+ * accept the join and the first listener registered would win, which
90
+ * is the non-determinism the address exists to remove. So the second
91
+ * one is refused at construction, where the mistake is legible,
92
+ * rather than silently resolved by registration order.
93
+ *
94
+ * Keyed weakly on the transport OBJECT, which makes this check
95
+ * BEST-EFFORT by construction: a page that wraps its node in a fresh
96
+ * object per store — the harness does exactly that for its joiners —
97
+ * presents a different key each time and gets no warning. That is
98
+ * acceptable precisely because correctness does NOT rest here. A
99
+ * join names its store (`join.store`, `owner.ts`), and an owner that
100
+ * is not the addressee refuses it whatever this table says. This
101
+ * only turns one specific mistake — two stores answering to one name
102
+ * through one transport handle — from a silent ambiguity into a
103
+ * legible refusal.
104
+ */
105
+ const addressesByTransport = new WeakMap();
106
+ /**
107
+ * The stores hosted on each transport, in the order they started.
108
+ *
109
+ * Every hosted store on a node sees every frame, and every message but a
110
+ * join names a handle that exactly one of them issued. A store that does
111
+ * not know the handle must stay silent when a SIBLING knows it. Answering
112
+ * `closed` on the owner's behalf refused a live player's every action on
113
+ * a node serving two stores (two regions of a world). A handle nobody
114
+ * knows still gets its `closed`, from the first store alone.
115
+ */
116
+ const ownersByTransport = new WeakMap();
117
+ /** The handle a caller message names, without decoding it twice; `null` for a join or a non-message. */
118
+ function namedHandle(text) {
119
+ try {
120
+ const value = JSON.parse(text);
121
+ return value !== null && typeof value === 'object' && value.k !== 'join' && typeof value.h === 'string'
122
+ ? value.h
123
+ : null;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ const internals = new WeakMap();
130
+ /** The internals of a handle `hostStore` returned, or `undefined`. */
131
+ export function hostInternals(handle) {
132
+ return internals.get(handle);
133
+ }
134
+ function randomHex(bytes) {
135
+ const buffer = new Uint8Array(bytes);
136
+ crypto.getRandomValues(buffer);
137
+ let hex = '';
138
+ for (const byte of buffer)
139
+ hex += byte.toString(16).padStart(2, '0');
140
+ return hex;
141
+ }
142
+ const encoder = new TextEncoder();
143
+ const decoder = new TextDecoder();
144
+ /**
145
+ * Serve one store to the mesh.
146
+ *
147
+ * Every inbound frame is dispatched with the peer the TRANSPORT
148
+ * authenticated. A frame whose event carries no peer is dropped and
149
+ * counted rather than dispatched with a guess: on a follower's proxied
150
+ * handle that field was once absent, and admitting those frames is the
151
+ * cross-peer admixture the identity work removed.
152
+ */
153
+ export function hostStore(options) {
154
+ const streamId = options.streamId ?? `store/${options.definition.id}`;
155
+ const address = options.store ?? options.definition.id;
156
+ const taken = addressesByTransport.get(options.transport) ?? new Set();
157
+ if (taken.has(address)) {
158
+ throw new StoreError('invalid-data', `a store named ${address} is already hosted on this transport: name each store of ` +
159
+ 'one definition, or a join for that name is ambiguous');
160
+ }
161
+ taken.add(address);
162
+ addressesByTransport.set(options.transport, taken);
163
+ const owner = new StoreOwner({
164
+ definition: options.definition,
165
+ store: address,
166
+ authorize: options.authorize,
167
+ project: options.project,
168
+ projectFor: options.projectFor,
169
+ onEvent: options.onEvent,
170
+ areaOf: options.areaOf,
171
+ warn: options.dev === true
172
+ ? message => console.warn(`[@net-mesh/browser] ${message}`)
173
+ : typeof options.dev === 'function'
174
+ ? options.dev
175
+ : undefined,
176
+ actions: options.actions,
177
+ inputs: options.inputs,
178
+ maxEventBytes: options.maxEventBytes,
179
+ now: options.now ?? (() => Date.now()),
180
+ newHandle: options.newHandle ?? (() => randomHex(16)),
181
+ newIncarnation: options.newIncarnation ?? (() => randomHex(8)),
182
+ canProject: options.canProject ?? (() => true),
183
+ });
184
+ // Declared secrets become the HIDDEN marker in a player's view, and
185
+ // the view must pass the state validator. Checked now, against the
186
+ // initial state as a stranger would see it, so a validator that
187
+ // refuses the marker fails here rather than sending every player
188
+ // `empty()` in silence.
189
+ if (options.definition.visibility !== undefined) {
190
+ const stranger = applyVisibility(compileVisibility(options.definition.visibility), options.initialState, null);
191
+ try {
192
+ options.definition.state(stranger);
193
+ }
194
+ catch (error) {
195
+ throw new StoreError('invalid-data', `store '${options.definition.id}': the state validator rejects a view with secrets hidden — ` +
196
+ `wrap each field a rule hides with hiddenOr(…): ${error instanceof Error ? error.message : String(error)}`, { cause: error });
197
+ }
198
+ }
199
+ owner.commit(options.initialState);
200
+ const replies = new Map();
201
+ /**
202
+ * The derived stream id this store's traffic arrives on, once one
203
+ * frame has established it.
204
+ *
205
+ * The label cannot be compared against an event: the event carries
206
+ * the id the leaf DERIVED from it. And the derivation lives in Rust
207
+ * (`stream_id_from_label`), so this side cannot compute it without
208
+ * duplicating that hash and inviting drift. What it can do is learn
209
+ * it: the first store frame fixes the id, and every later frame must
210
+ * match. The window is exactly one frame wide and it is bounded by
211
+ * the checks that do not depend on the id at all — the peer is
212
+ * authenticated by the transport, a `join` must name this
213
+ * definition and version, and every other kind must name a handle
214
+ * this owner issued.
215
+ */
216
+ let arrivesOn = null;
217
+ const pendingReplies = new Map();
218
+ const dropped = {};
219
+ let closed = false;
220
+ /** The one teardown in flight, so a second `close()` joins it. */
221
+ let closing = null;
222
+ async function replyStream(peer) {
223
+ const open = replies.get(peer);
224
+ if (open !== undefined)
225
+ return open;
226
+ const inFlight = pendingReplies.get(peer);
227
+ if (inFlight !== undefined)
228
+ return inFlight;
229
+ // One reply stream per peer, opened lazily: a store that never
230
+ // answers a peer never opens one.
231
+ // `peer` is the owner's, and the owner is only ever given the
232
+ // normalized 16-hex form (see the dispatch below), so there is no
233
+ // second conversion here — a conversion that cannot change its
234
+ // input is a claim.
235
+ const opening = Promise.resolve(options.transport.openStream({ reliability: 'reliable', peer, label: streamId })).then(stream => {
236
+ pendingReplies.delete(peer);
237
+ // An open that RESOLVES after `close()` is reclaimed, not
238
+ // published: publishing it leaves a live stream behind a
239
+ // closed store, which is the second half of the same defect.
240
+ if (closed) {
241
+ try {
242
+ stream.close();
243
+ }
244
+ catch {
245
+ // Nothing to do: the point was not to leave it open.
246
+ }
247
+ throw new StoreError('closed', 'the store closed while its stream was opening');
248
+ }
249
+ replies.set(peer, stream);
250
+ return stream;
251
+ });
252
+ const tracked = opening.catch(error => {
253
+ // A failed first open must not be cached as this peer's fate. The
254
+ // success path above is what removes the entry, so without this the
255
+ // map holds a REJECTED promise and every later frame for the peer
256
+ // takes the `inFlight` branch and receives the same stale rejection
257
+ // — one transient `openStream` failure during the session-replacement
258
+ // window wedges that peer's service for the lifetime of the store,
259
+ // with every future commit's frames counted `send-failed`. The
260
+ // stale-send reopen path below deletes both entries; this is its
261
+ // first-open counterpart.
262
+ pendingReplies.delete(peer);
263
+ throw error;
264
+ });
265
+ pendingReplies.set(peer, tracked);
266
+ return tracked;
267
+ }
268
+ async function emit(out) {
269
+ for (const frame of out) {
270
+ if (closed)
271
+ return;
272
+ const payload = encoder.encode(frame.frame);
273
+ const stream = await replyStream(frame.peer);
274
+ try {
275
+ await stream.send(payload);
276
+ }
277
+ catch (error) {
278
+ // CLOSURE WINS OVER A LATE REJECTION. The rejection arrives
279
+ // after an await, so `close()` can have happened in between:
280
+ // reopening then revives a closed store's transport — a new
281
+ // stream stays open and the frame is DELIVERED, which a
282
+ // review probe measured on both sides (opens 1 → 2, delta
283
+ // applied at a receiver after the host had closed).
284
+ if (closed)
285
+ return;
286
+ // Reopen for the stale-handle refusal and nothing else: see
287
+ // `isStaleStream`. A permanent failure that reopened per
288
+ // frame consumed a stream handle per frame.
289
+ if (!isStaleStream(error))
290
+ throw error;
291
+ // A HELD STREAM HANDLE DOES NOT SURVIVE ITS SESSION.
292
+ //
293
+ // When a pair is promoted from relayed to direct (§9 step 4)
294
+ // the session is REPLACED, and every stream opened on the
295
+ // predecessor is refused: "stale stream handle: opened on
296
+ // incarnation N … reopen the stream". A store that cached
297
+ // one therefore stopped delivering the moment its pair got
298
+ // better — silently, because the send is a promise nobody
299
+ // awaited. Measured: the Stage 7 direct-path witness
300
+ // promoted the pair, the anchor's per-pair counter went
301
+ // exactly flat, and the replica never saw the commit.
302
+ //
303
+ // So a failed send drops the handle and reopens ONCE. Not a
304
+ // retry loop: if the second attempt fails the failure is
305
+ // real and belongs to the caller.
306
+ // ONE reopen per peer per failure generation. `emit` runs
307
+ // fire-and-forget once per dispatched frame, so on a session
308
+ // replacement several emits to one peer fail at once — and
309
+ // the first version had the second failure delete the
310
+ // in-flight promise the first reopen registered, opening a
311
+ // SECOND reply stream for the same peer, orphaning the loser
312
+ // and fencing the derived id on the real leaf.
313
+ if (replies.get(frame.peer) === stream) {
314
+ replies.delete(frame.peer);
315
+ pendingReplies.delete(frame.peer);
316
+ dropped['reopened-stream'] = (dropped['reopened-stream'] ?? 0) + 1;
317
+ try {
318
+ stream.close();
319
+ }
320
+ catch {
321
+ // Already gone; the point was not to leave it open.
322
+ }
323
+ }
324
+ const reopened = await replyStream(frame.peer);
325
+ await reopened.send(payload);
326
+ }
327
+ }
328
+ }
329
+ /**
330
+ * A detached send that failed, OWNED.
331
+ *
332
+ * `emit` is invoked fire-and-forget — a commit answers every
333
+ * installed handle and nothing awaits it — so a rejected send has
334
+ * no caller to reach. Before this it had no owner either: the
335
+ * promise was `void`ed, and a permanent failure (an oversized
336
+ * payload, a node that is gone) surfaced as an UNHANDLED
337
+ * REJECTION. The store's own test run then exited non-zero with
338
+ * five of them while reporting 667 assertions passed, and CI's
339
+ * browser-package job failed the same way — a real defect the
340
+ * runner was right to refuse.
341
+ *
342
+ * So the loss is counted where every other undeliverable frame is
343
+ * counted, and the host stays up: one replica's dead transport is
344
+ * not the other replicas' problem.
345
+ */
346
+ function detached(work) {
347
+ void work.catch(() => {
348
+ dropped['send-failed'] = (dropped['send-failed'] ?? 0) + 1;
349
+ });
350
+ }
351
+ function dispatched(result) {
352
+ detached(emit(result.out));
353
+ // §1.10's large-input path answers later; the transport sends that
354
+ // reply exactly like the synchronous one, or the caller waits on a
355
+ // result that was computed and never sent.
356
+ if (result.deferred !== null) {
357
+ detached(result.deferred.then(later => dispatched(later)));
358
+ }
359
+ flushEvents();
360
+ }
361
+ /**
362
+ * Run the `onEvent` hook for whatever the frame just dispatched
363
+ * caused — a join, a leave, an area change — AFTER that frame's own
364
+ * output is on its way, and send what the hook changed. Never
365
+ * re-entered: a hook's own changes are drained by the same call.
366
+ */
367
+ let flushing = false;
368
+ function flushEvents() {
369
+ if (flushing || closed)
370
+ return;
371
+ flushing = true;
372
+ try {
373
+ const drained = owner.drainEvents();
374
+ if (drained.out.length > 0)
375
+ detached(emit(drained.out));
376
+ }
377
+ finally {
378
+ flushing = false;
379
+ }
380
+ }
381
+ const siblings = ownersByTransport.get(options.transport) ?? new Set();
382
+ siblings.add(owner);
383
+ ownersByTransport.set(options.transport, siblings);
384
+ const unsubscribe = options.transport.onEvent(event => {
385
+ // No `closed` test here: `close` UNSUBSCRIBES, so a closed host
386
+ // receives nothing to test. A second check would be a claim, and
387
+ // the mechanism is witnessed instead ("stops serving once
388
+ // closed").
389
+ if (event.type !== 'stream_data')
390
+ return;
391
+ // NOT compared against the label: the event carries the leaf's
392
+ // DERIVED numeric id, and comparing a decimal wire id with the
393
+ // label it was derived from is never equal, so this filter used to
394
+ // drop every store frame. What identifies this store's traffic is
395
+ // the frame itself — `decodeMessage` refuses anything that is not
396
+ // a caller message, `join` must name this definition and version,
397
+ // and every other kind must name a handle THIS owner issued. The
398
+ // stream id is a route; the peer is the identity.
399
+ const peer = event.peerNode;
400
+ if (typeof peer !== 'string' || peer.length === 0) {
401
+ // No authenticated peer, no dispatch. There is no second source
402
+ // of identity to fall back to, and inventing one is the whole
403
+ // defect class this gate exists for.
404
+ dropped['no-authenticated-peer'] = (dropped['no-authenticated-peer'] ?? 0) + 1;
405
+ return;
406
+ }
407
+ const payload = event.payload;
408
+ if (payload === undefined) {
409
+ dropped['no-payload'] = (dropped['no-payload'] ?? 0) + 1;
410
+ return;
411
+ }
412
+ const arrived = event.streamId;
413
+ if (arrivesOn !== null && arrived !== undefined && arrived !== arrivesOn) {
414
+ // Another stream on this session. Not this store's traffic.
415
+ dropped['foreign-stream'] = (dropped['foreign-stream'] ?? 0) + 1;
416
+ return;
417
+ }
418
+ let text;
419
+ try {
420
+ text = decoder.decode(payload);
421
+ }
422
+ catch {
423
+ dropped['undecodable-frame'] = (dropped['undecodable-frame'] ?? 0) + 1;
424
+ return;
425
+ }
426
+ // The peer in the spelling the store's contract documents — 16
427
+ // lowercase hex — because `AccessRequest.peer` and
428
+ // `ActionContext.peer` say so and a policy comparing against a
429
+ // node id it printed itself must not be handed a decimal.
430
+ const authenticated = peerHexOf(peer);
431
+ if (authenticated === null) {
432
+ dropped['unusable-peer'] = (dropped['unusable-peer'] ?? 0) + 1;
433
+ return;
434
+ }
435
+ // Pin the stream id from a frame the OWNER ACCEPTED, never from
436
+ // one it then refuses.
437
+ //
438
+ // Every hostStore on a node sees every frame on it, and a node
439
+ // may host stores of DIFFERENT definitions: pinning whatever
440
+ // arrived first would make a store claim a stream belonging to
441
+ // another store entirely, and then refuse all of its own traffic
442
+ // as `foreign-stream` for the life of the page. Which store a
443
+ // JOIN is for is decided before this, by `join.store`
444
+ // (`owner.ts`); this is what keeps the id learned from it right.
445
+ const siblings = ownersByTransport.get(options.transport);
446
+ if (siblings !== undefined && siblings.size > 1) {
447
+ const h = namedHandle(text);
448
+ if (h !== null && owner.handle(h) === undefined) {
449
+ // Not ours. The store that issued it answers; if none did, only
450
+ // the first store says `closed`, so the caller hears it once.
451
+ let ownedElsewhere = false;
452
+ for (const sibling of siblings) {
453
+ if (sibling !== owner && sibling.handle(h) !== undefined)
454
+ ownedElsewhere = true;
455
+ }
456
+ if (ownedElsewhere || siblings.values().next().value !== owner) {
457
+ dropped['sibling-handle'] = (dropped['sibling-handle'] ?? 0) + 1;
458
+ return;
459
+ }
460
+ }
461
+ }
462
+ const outcome = owner.receive(text, authenticated);
463
+ if (arrived !== undefined && arrivesOn === null && outcome.refused === null) {
464
+ arrivesOn = arrived;
465
+ }
466
+ dispatched(outcome);
467
+ });
468
+ const schedule = options.schedule ?? ((run, ms) => {
469
+ const timer = setInterval(run, ms);
470
+ return () => {
471
+ clearInterval(timer);
472
+ };
473
+ });
474
+ const stopSweep = schedule(() => {
475
+ if (closed)
476
+ return;
477
+ const now = (options.now ?? (() => Date.now()))();
478
+ const expired = owner.sweep(now);
479
+ if (expired.length > 0) {
480
+ // §1.6's expiry notice: a client that was merely slow learns why
481
+ // rather than inferring it from silence. Only to a peer this host
482
+ // already has a reply stream for — if no session is up, nothing
483
+ // is sent and nothing is retained to announce later.
484
+ detached(emit(expired
485
+ .filter(entry => replies.has(entry.peer))
486
+ .map(entry => ({
487
+ peer: entry.peer,
488
+ h: entry.h,
489
+ frame: encodeMessage({ k: 'no', h: entry.h, code: 'closed' }),
490
+ }))));
491
+ }
492
+ // A projection may have become available while a control waited.
493
+ dispatched(owner.resumeDeferred(now));
494
+ }, HOST_SWEEP_MS);
495
+ /**
496
+ * Say goodbye, then go — in that order, and with each half owned.
497
+ *
498
+ * Every rule here is a repaired defect, all five found by review
499
+ * probes against the built package:
500
+ *
501
+ * 1. **Stop serving FIRST.** `unsubscribe()` used to run after the
502
+ * awaited goodbye, so for the whole duration of the farewell
503
+ * the host still dispatched arrivals: a `join` landing in that
504
+ * window was ADMITTED and BOUND, and then got nothing — its
505
+ * manifest dropped by the post-close reclaim, and the goodbye
506
+ * batch computed before its handle existed. The window was ~zero
507
+ * until `close()` awaited anything; awaiting opened it.
508
+ * 2. **One frame per handle, independently.** `emit` is a
509
+ * sequential loop that rethrows the first permanent failure, so
510
+ * one replica with a dead transport silenced every handle behind
511
+ * it: they were forgotten by `farewell()` without being told,
512
+ * and fell back to the ten-second `indeterminate` this whole
513
+ * mechanism exists to remove.
514
+ * 3. **On the stream that already exists, never a new one.** The
515
+ * previous filter (`replies.has(peer)`) did not establish that:
516
+ * `emit`'s stale-handle reopen lives INSIDE it, so a reply
517
+ * stream staled by a §9 promotion — the promotion this stage
518
+ * performs — had `close()` OPEN a stream while tearing streams
519
+ * down (measured: opens 1 → 2).
520
+ * 4. **Bounded.** A send that never settles must not make closure
521
+ * never return.
522
+ * 5. **Latched** by the caller above.
523
+ *
524
+ * Every undelivered goodbye is counted, one per handle, because a
525
+ * single `farewell-failed` for an aborted batch said nothing about
526
+ * how many replicas were skipped.
527
+ */
528
+ async function shutdown() {
529
+ // Serving stops here, before a single goodbye is composed: after
530
+ // this line nothing new can be admitted, so the set of handles
531
+ // owed a farewell cannot grow while one is being sent.
532
+ closed = true;
533
+ stopSweep();
534
+ unsubscribe();
535
+ const goodbye = owner.farewell();
536
+ await Promise.all(goodbye.map(async (out) => {
537
+ const stream = replies.get(out.peer);
538
+ if (stream === undefined) {
539
+ // No stream and none opened: a peer with no live reply
540
+ // stream is unreachable, and opening one from the teardown
541
+ // path is defect 3 above.
542
+ dropped['farewell-unreachable'] = (dropped['farewell-unreachable'] ?? 0) + 1;
543
+ return;
544
+ }
545
+ try {
546
+ await bounded(Promise.resolve(stream.send(encoder.encode(out.frame))));
547
+ }
548
+ catch {
549
+ // This replica's transport is gone. Counted, and the next
550
+ // replica is still told.
551
+ dropped['farewell-failed'] = (dropped['farewell-failed'] ?? 0) + 1;
552
+ }
553
+ }));
554
+ for (const stream of replies.values()) {
555
+ try {
556
+ stream.close();
557
+ }
558
+ catch {
559
+ // Already gone; the point was not to leave it open — the
560
+ // same catch `emit` and `replyStream` use, and `close()` is
561
+ // a CONSUMER-supplied method on `TransportStream`, so this
562
+ // file's own standard is that it may throw.
563
+ //
564
+ // Unwrapped, one throwing stream abandoned everything below:
565
+ // the name stayed taken on that transport for ever, so no
566
+ // successor could host it, and the latch cached the
567
+ // rejection — every later `close()` returned the same
568
+ // rejected promise, which is the exact "explicit close plus
569
+ // an unload/dispose" pattern the latch exists to serve. Each
570
+ // stream is closed on its own for the same reason the
571
+ // goodbyes are sent on their own.
572
+ dropped['stream-close-failed'] = (dropped['stream-close-failed'] ?? 0) + 1;
573
+ }
574
+ }
575
+ replies.clear();
576
+ pendingReplies.clear();
577
+ // The name is free again, so a successor may take it.
578
+ addressesByTransport.get(options.transport)?.delete(address);
579
+ ownersByTransport.get(options.transport)?.delete(owner);
580
+ }
581
+ /**
582
+ * The goodbye, with a deadline.
583
+ *
584
+ * A transport whose `send` never settles would otherwise make
585
+ * `close()` never return, and a page tearing down cannot wait
586
+ * forever to be polite. The frame is not retried: a farewell that
587
+ * missed its window is a counted loss, not a queue.
588
+ */
589
+ function bounded(work) {
590
+ return new Promise((resolve, reject) => {
591
+ const expire = () => {
592
+ done();
593
+ reject(new StoreError('timeout', 'the farewell did not flush before the deadline'));
594
+ };
595
+ // TWO clocks, and both are needed. The injected `schedule` is
596
+ // what a deterministic test advances, so a witness can prove
597
+ // the bound without waiting two real seconds — but a caller
598
+ // that injects a clock and never advances it would then have
599
+ // an UNBOUNDED close, which is the defect. Real time is
600
+ // therefore the floor and the injected clock is the fast path.
601
+ const cancel = schedule(expire, FAREWELL_DEADLINE_MS);
602
+ const timer = setTimeout(expire, FAREWELL_DEADLINE_MS);
603
+ const done = () => {
604
+ cancel();
605
+ clearTimeout(timer);
606
+ };
607
+ work.then(() => {
608
+ done();
609
+ resolve();
610
+ }, error => {
611
+ done();
612
+ reject(error instanceof Error ? error : new Error(String(error)));
613
+ });
614
+ });
615
+ }
616
+ const authority = options.transport.nodeIdHex() ?? owner.incarnationHex;
617
+ const closeListeners = new Set();
618
+ const handle = {
619
+ authority,
620
+ definition: options.definition,
621
+ getState: () => owner.getState(),
622
+ subscribe: listener => owner.subscribe(listener),
623
+ setState: next => {
624
+ // A commit tells every installed handle what changed; dropping
625
+ // that return would leave every replica at the revision it
626
+ // joined on.
627
+ dispatched(owner.commit(next));
628
+ },
629
+ setEntities: (collection, changes) => {
630
+ dispatched(owner.commitEntities(collection, changes));
631
+ },
632
+ setEntity: (collection, id, value) => {
633
+ const changes = {};
634
+ Object.defineProperty(changes, id, { value, enumerable: true, writable: true, configurable: true });
635
+ dispatched(owner.commitEntities(collection, changes));
636
+ },
637
+ counts: () => ({
638
+ handles: owner.handleCount,
639
+ ledgers: owner.ledgerCount,
640
+ deferred: owner.deferredCount,
641
+ sparseViews: owner.sparseViews,
642
+ }),
643
+ counters: () => ({ ...owner.snapshotCounters(), ...dropped }),
644
+ close: () => {
645
+ // LATCHED. `close()` returns a promise and teardown paths call
646
+ // it twice (an explicit close plus an unload/dispose): the
647
+ // previous guard tested a flag set only AFTER the awaited
648
+ // goodbye, so a second call re-entered, found `farewell()`
649
+ // already spent — it is a one-shot — and closed the reply
650
+ // streams out from under the frames still in flight. A review
651
+ // probe measured ZERO goodbyes delivered under a concurrent
652
+ // close where one call delivers one.
653
+ if (closing === null) {
654
+ closing = shutdown();
655
+ for (const listener of [...closeListeners])
656
+ listener();
657
+ closeListeners.clear();
658
+ }
659
+ return closing;
660
+ },
661
+ };
662
+ internals.set(handle, {
663
+ owner: owner,
664
+ // The spelling `AccessRequest.peer` and `ActionContext.peer`
665
+ // promise: 16 lowercase hex, as a replica's frames are handed.
666
+ peer: peerHexOf(authority) ?? authority,
667
+ maxEventBytes: options.maxEventBytes,
668
+ dispatched,
669
+ isClosed: () => closed,
670
+ onClose: listener => {
671
+ closeListeners.add(listener);
672
+ },
673
+ });
674
+ return handle;
675
+ }