@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,724 @@
1
+ /**
2
+ * Slice G, caller half — `joinStore`.
3
+ *
4
+ * The replica (slice D/F) decides what may be published; this decides
5
+ * nothing and carries frames, keeps the lease alive, and turns the
6
+ * protocol's correlation into the promises a page awaits.
7
+ *
8
+ * ## The lease is store machinery, never game code
9
+ *
10
+ * `alive` goes out every 20 s while the handle is live, because a page
11
+ * rendering a spectator view sends nothing and expiring it would be a
12
+ * defect (§1.6). The honest statement about the pair: a 20 s interval
13
+ * against a 60 s lease tolerates **one** lost renewal with margin, and
14
+ * the second is a race — the third renewal lands *at* the boundary
15
+ * before any scheduling delay is counted.
16
+ *
17
+ * ## Correlation, and what a timeout does not mean
18
+ *
19
+ * An action that was never submitted is known not to have executed. An
20
+ * action that was submitted and then timed out is `indeterminate`, and
21
+ * the store says exactly that rather than retrying: a silent resend is
22
+ * how one action becomes two.
23
+ */
24
+ import { StoreCore } from './core.js';
25
+ import { isStaleStream, StoreError } from './errors.js';
26
+ import { peerHexOf, samePeer } from './host.js';
27
+ import { StoreReplica } from './replica.js';
28
+ import { decodeMessage, encodeMessage, utf8Length } from './wire.js';
29
+ /** How often a live handle renews its lease (§1.6). */
30
+ export const ALIVE_INTERVAL_MS = 20_000;
31
+ /** How long a request waits before it is a typed timeout (§2). */
32
+ export const REQUEST_DEADLINE_MS = 10_000;
33
+ /**
34
+ * How long one TRANSITION call waits for its installation before it
35
+ * is a typed timeout (§2).
36
+ *
37
+ * A transition's answer is its INSTALLATION, never the request
38
+ * clock's — a snapshot still transferring at 10 s is a success in
39
+ * progress — but that exemption needs a fence of its own: an owner
40
+ * that answers every `resync`/`join` with a fresh `man` whose
41
+ * assembly never completes restarts the 10 s assembly deadline each
42
+ * time, and the `#abandon → #resync → man` loop would hold the call
43
+ * (and its `MAX_OUTSTANDING` slot) for ever. So every call carries
44
+ * this named wall-clock bound from its own start. A timed-out call
45
+ * removes only that waiter (`cancelWaiter`), and the slot goes with
46
+ * its last caller.
47
+ */
48
+ export const TRANSITION_DEADLINE_MS = 60_000;
49
+ /**
50
+ * How often the replica's own clock is driven.
51
+ *
52
+ * The assembly deadline is 10 s (`assembly.ts`), so a second is fine
53
+ * resolution and cheap: the tick does nothing at all unless an
54
+ * assembly is open and overdue.
55
+ */
56
+ export const TICK_INTERVAL_MS = 1_000;
57
+ /** Outstanding correlations per caller (§2). */
58
+ export const MAX_OUTSTANDING = 64;
59
+ const encoder = new TextEncoder();
60
+ const decoder = new TextDecoder();
61
+ function randomHex(bytes) {
62
+ const buffer = new Uint8Array(bytes);
63
+ crypto.getRandomValues(buffer);
64
+ let hex = '';
65
+ for (const byte of buffer)
66
+ hex += byte.toString(16).padStart(2, '0');
67
+ return hex;
68
+ }
69
+ /**
70
+ * Join a store hosted by `host`.
71
+ *
72
+ * Returns as soon as the join is sent; `ready()` is what waits for a
73
+ * consistent view, because a caller that wants to render a loading
74
+ * state should not have to await the world first.
75
+ */
76
+ export function joinStore(options) {
77
+ // A replica of the node it is running on cannot be reached over
78
+ // the mesh: `openStream({peer})` needs a SESSION with that peer,
79
+ // and a node has no session with itself. The fleet demo's mesh mode
80
+ // asked for exactly this — the hosting tab played through
81
+ // `joinStore({host: self})` — and what a real browser reported was
82
+ // `no session with 0x…`, from inside the transport, after the store
83
+ // had already accepted the subscription. Refused here instead,
84
+ // where the caller can read what it did wrong.
85
+ const selfId = options.transport.nodeIdHex();
86
+ if (selfId !== null && samePeer(selfId, options.host)) {
87
+ throw new StoreError('invalid-data', 'a replica cannot join the node it runs on: hold the hostStore handle instead');
88
+ }
89
+ const streamId = options.streamId ?? `store/${options.definition.id}`;
90
+ const now = options.now ?? (() => Date.now());
91
+ const schedule = options.schedule ??
92
+ ((run, ms) => {
93
+ const timer = setInterval(run, ms);
94
+ return () => {
95
+ clearInterval(timer);
96
+ };
97
+ });
98
+ const core = new StoreCore({
99
+ definition: options.definition,
100
+ initialState: options.definition.empty(),
101
+ });
102
+ const replica = new StoreReplica({
103
+ definition: options.definition,
104
+ core,
105
+ maxEventBytes: options.maxEventBytes,
106
+ now,
107
+ newQ: () => (options.newQ ?? (() => randomHex(8)))(),
108
+ audience: options.audience,
109
+ store: options.store ?? options.definition.id,
110
+ key: options.key,
111
+ ...(options.interest === undefined ? {} : { interest: options.interest }),
112
+ });
113
+ const outstanding = new Map();
114
+ const inputSequences = new Map();
115
+ let sequence = 0n;
116
+ let closed = false;
117
+ let upstream = null;
118
+ let opening = null;
119
+ const readyWaiters = [];
120
+ /** The transition in flight and its callers (§2). */
121
+ let transition = null;
122
+ async function stream() {
123
+ if (upstream !== null)
124
+ return upstream;
125
+ if (opening !== null)
126
+ return opening;
127
+ const addressable = peerHexOf(options.host);
128
+ if (addressable === null) {
129
+ return Promise.reject(new StoreError('invalid-data', `the host id is not a node id: ${options.host}`));
130
+ }
131
+ // `label`, never `streamId`: see {@link StoreTransport.openStream}.
132
+ // A textual id is refused by the wasm option reader, which is why
133
+ // this used to fail before a single byte was sent — and an
134
+ // arbitrary numeric one would lose the discriminator bit that makes
135
+ // the far end classify an unsolicited arrival as stream data.
136
+ //
137
+ // And a session first, because `openStream` refuses a peer the
138
+ // node has no session with. A failed attempt is not fatal here:
139
+ // the relay is installed before the direct half is even tried, so
140
+ // the open below is the thing that decides, and its refusal is
141
+ // typed.
142
+ opening = Promise.resolve(options.transport.connectPeer?.(addressable))
143
+ .catch(() => undefined)
144
+ .then(() => options.transport.openStream({
145
+ reliability: 'reliable',
146
+ peer: addressable,
147
+ label: streamId,
148
+ }))
149
+ .then(open => {
150
+ opening = null;
151
+ // An open that resolves after `close()` is reclaimed, never
152
+ // published.
153
+ if (closed) {
154
+ try {
155
+ open.close();
156
+ }
157
+ catch {
158
+ // Already gone.
159
+ }
160
+ throw new StoreError('closed', 'the store closed while its stream was opening');
161
+ }
162
+ upstream = open;
163
+ return open;
164
+ })
165
+ .catch(error => {
166
+ // The success path above is what clears the cached open; without
167
+ // this, a REJECTED one is kept and every later `send`/`act` is
168
+ // answered from it with the same stale error until the caller
169
+ // happens to call `reconnect()`. A transient failure is a retry,
170
+ // not a verdict on the peer.
171
+ opening = null;
172
+ throw error;
173
+ });
174
+ return opening;
175
+ }
176
+ /** Frames, not requests: an `act` is not a `Request` kind. */
177
+ async function send(frames) {
178
+ for (const frame of frames) {
179
+ if (closed)
180
+ return;
181
+ const payload = encoder.encode(frame);
182
+ const open = await stream();
183
+ try {
184
+ await open.send(payload);
185
+ }
186
+ catch (error) {
187
+ // A HELD STREAM HANDLE DOES NOT SURVIVE ITS SESSION. When a
188
+ // pair is promoted from relayed to direct (§9 step 4) the
189
+ // session is REPLACED and streams opened on the predecessor
190
+ // are refused as stale — so a replica that cached one stopped
191
+ // talking the moment its pair got better.
192
+ //
193
+ // Reopen for THAT refusal and nothing else. An oversized
194
+ // payload or a fenced id cannot be repaired by a new stream,
195
+ // and the first version reopened for every failure: five
196
+ // undeliverable frames consumed five stream handles and
197
+ // released none, against a per-owner budget of 256.
198
+ // CLOSURE WINS OVER A LATE REJECTION: the rejection lands
199
+ // after an await, so `close()` may have happened in
200
+ // between, and reopening then revives a closed replica's
201
+ // transport — a review probe watched a held JOIN be sent
202
+ // and ADMITTED by the host after `close()` resolved.
203
+ if (closed)
204
+ return;
205
+ if (!isStaleStream(error))
206
+ throw error;
207
+ // ONE reopen per failure generation. `stream()` returns an
208
+ // in-flight open, so a concurrent failure JOINS this reopen
209
+ // instead of replacing it — the first version cleared the
210
+ // sibling's `opening` and issued a second `openStream` on the
211
+ // same derived label, which orphans the loser and, on the real
212
+ // leaf, FENCES the id terminally. That is the very defect this
213
+ // repair exists to remove, reachable from the repair itself.
214
+ if (upstream === open) {
215
+ upstream = null;
216
+ opening = null;
217
+ try {
218
+ open.close();
219
+ }
220
+ catch {
221
+ // A stream that cannot be closed is already gone; the
222
+ // point was not to leave it open.
223
+ }
224
+ }
225
+ const reopened = await stream();
226
+ await reopened.send(payload);
227
+ }
228
+ }
229
+ }
230
+ /**
231
+ * Fail everything waiting, because the transport could not carry it.
232
+ *
233
+ * The fire-and-forget sends were `void`ed, so a rejected `openStream`
234
+ * became an unhandled rejection and `ready()` stayed pending for
235
+ * ever — the page waited out its own deadline with the real reason
236
+ * on the floor. Every path that cannot send now ends here.
237
+ */
238
+ function fail(error) {
239
+ const reason = error instanceof StoreError
240
+ ? error
241
+ : new StoreError('indeterminate', `the transport could not carry a store frame: ${String(error)}`);
242
+ for (const waiter of readyWaiters.splice(0, readyWaiters.length))
243
+ waiter.reject(reason);
244
+ // The transition's callers too: a slot-less one (the construction
245
+ // join) has no `outstanding` entry to reject it from below.
246
+ settleTransitions(reason);
247
+ for (const [q, waiter] of [...outstanding]) {
248
+ outstanding.delete(q);
249
+ waiter.reject(reason);
250
+ }
251
+ }
252
+ /** Send, and report a failure to whoever is waiting. */
253
+ function dispatch(frames) {
254
+ void send(frames).catch(fail);
255
+ }
256
+ const framesOf = (requests) => requests.map(request => request.frame);
257
+ function settleReady(refusal) {
258
+ if (replica.state === 'ready') {
259
+ // The INSTALLATION is a transition's answer (§2), so its
260
+ // correlation settles here: holding it to the request deadline
261
+ // both kept an `outstanding` slot for 10 s after a success and
262
+ // rejected a still-installing transition as `indeterminate`.
263
+ settleTransitions(null);
264
+ for (const waiter of readyWaiters.splice(0, readyWaiters.length))
265
+ waiter.resolve();
266
+ return;
267
+ }
268
+ if (replica.state !== 'fenced' && replica.state !== 'closed')
269
+ return;
270
+ // A fence or a terminal close is an ANSWER: the request was
271
+ // refused, or the document is gone. A `ready()` that stayed
272
+ // pending here would leave a page waiting for a world that is
273
+ // never coming — the caller would have to infer the refusal from
274
+ // silence, which is what §1.6 refuses to accept for expiry and is
275
+ // no better here.
276
+ const error = refusal ??
277
+ new StoreError(replica.state === 'closed' ? 'owner-lost' : 'aborted', `the store handle is ${replica.state}`);
278
+ settleTransitions(error);
279
+ for (const waiter of readyWaiters.splice(0, readyWaiters.length))
280
+ waiter.reject(error);
281
+ }
282
+ /**
283
+ * Settle the transition in flight: the installation resolved its
284
+ * callers, or `error` is the typed answer that ended them. `act`
285
+ * correlations are untouched — an installation is no answer to a
286
+ * request that has its own `res`/`ok`/`no` coming.
287
+ */
288
+ function settleTransitions(error) {
289
+ const current = transition;
290
+ if (current === null)
291
+ return;
292
+ transition = null;
293
+ if (current.q !== null)
294
+ outstanding.delete(current.q);
295
+ for (const call of [...current.calls]) {
296
+ current.calls.delete(call);
297
+ if (error === null)
298
+ call.resolve();
299
+ else
300
+ call.reject(error);
301
+ }
302
+ }
303
+ /**
304
+ * Take the transition slot — and SUPERSEDE the transition in
305
+ * flight (§1.7b). The superseded one can never install (its
306
+ * generation is fenced at the replica), so its callers are rejected
307
+ * now, never resolved over this transition's installation (#17),
308
+ * and its `MAX_OUTSTANDING` slot goes with them (#6).
309
+ */
310
+ function openTransition(q) {
311
+ settleTransitions(new StoreError('aborted', 'a newer transition superseded this one before it installed'));
312
+ transition = { q, calls: new Set() };
313
+ if (q === null)
314
+ return null;
315
+ if (outstanding.size >= MAX_OUTSTANDING) {
316
+ return new StoreError('capacity', 'too many requests are already outstanding');
317
+ }
318
+ outstanding.set(q, {
319
+ resolve: () => settleTransitions(null),
320
+ reject: (error) => settleTransitions(error),
321
+ at: now(),
322
+ transition: true,
323
+ });
324
+ return null;
325
+ }
326
+ /**
327
+ * Register one caller of the transition in flight — BEFORE its
328
+ * request goes out, or an installation that lands immediately finds
329
+ * nothing to settle — with its own deadline (#6).
330
+ */
331
+ function awaitTransition() {
332
+ return new Promise((resolve, reject) => {
333
+ const call = {
334
+ resolve,
335
+ reject,
336
+ deadlineAt: now() + TRANSITION_DEADLINE_MS,
337
+ };
338
+ const current = transition ?? { q: null, calls: new Set() };
339
+ transition = current;
340
+ current.calls.add(call);
341
+ });
342
+ }
343
+ const unsubscribe = options.transport.onEvent((event) => {
344
+ if (closed || event.type !== 'stream_data')
345
+ return;
346
+ // The stream id is NOT compared against the label: the event
347
+ // carries the leaf's DERIVED numeric id, so comparing it with the
348
+ // text it was derived from is never equal — this line dropped
349
+ // every frame the host ever sent. The host's peer plus the codec
350
+ // is what identifies this store's traffic.
351
+ // The host's identity, compared as a NUMBER: the event carries an
352
+ // exact decimal and `options.host` is hex, so `!==` on the strings
353
+ // rejected every frame the host ever sent.
354
+ if (typeof event.peerNode !== 'string' || !samePeer(event.peerNode, options.host))
355
+ return;
356
+ const payload = event.payload;
357
+ if (payload === undefined)
358
+ return;
359
+ const text = decoder.decode(payload);
360
+ // Correlated replies (`res`/`ok`/`no` for a request this caller
361
+ // made) resolve a promise; everything else is the replica's.
362
+ const decoded = decodeMessage(text, { maxBytes: options.maxEventBytes, as: 'replica' });
363
+ if (decoded.ok && 'q' in decoded.message && typeof decoded.message.q === 'string') {
364
+ const waiter = outstanding.get(decoded.message.q);
365
+ if (waiter !== undefined && (decoded.message.k === 'res' || decoded.message.k === 'ok')) {
366
+ outstanding.delete(decoded.message.q);
367
+ waiter.resolve(decoded.message.k === 'res' ? decoded.message.out : undefined);
368
+ return;
369
+ }
370
+ if (waiter !== undefined && decoded.message.k === 'no') {
371
+ outstanding.delete(decoded.message.q);
372
+ waiter.reject(new StoreError(decoded.message.code, `the store refused: ${decoded.message.code}`));
373
+ // A refusal is also news for the replica's own state machine
374
+ // (a fence, a rejoin, a terminal close), so it still goes on.
375
+ }
376
+ }
377
+ let refusal = null;
378
+ if (decoded.ok && decoded.message.k === 'no') {
379
+ refusal = new StoreError(decoded.message.code, `the store refused: ${decoded.message.code}`);
380
+ }
381
+ const received = replica.receive(text);
382
+ dispatch(framesOf(received.out));
383
+ settleReady(refusal);
384
+ });
385
+ const stopAlive = schedule(() => {
386
+ if (closed)
387
+ return;
388
+ const h = replica.handle;
389
+ // A handle is all this needs, and a closed replica has none: the
390
+ // terminal paths (`no {closed}`, `no {owner-lost}`) discard it, so
391
+ // a second `state === 'closed'` test here could not fire.
392
+ if (h === null)
393
+ return;
394
+ dispatch([encodeMessage({ k: 'alive', q: randomHex(8), h })]);
395
+ }, ALIVE_INTERVAL_MS);
396
+ const stopDeadlines = schedule(() => {
397
+ if (closed)
398
+ return;
399
+ const at = now();
400
+ // A transition CALL is settled by its installation or its typed
401
+ // refusal — and bounded by its own wall clock (#6): the re-ask
402
+ // ladder recovers the SILENCE case, but an owner answering every
403
+ // ask with a manifest whose assembly never completes would hold
404
+ // the call (and its `MAX_OUTSTANDING` slot) for ever. A timed-out
405
+ // call removes only that waiter, and the slot goes with its last
406
+ // caller.
407
+ const current = transition;
408
+ if (current !== null) {
409
+ for (const call of [...current.calls]) {
410
+ if (at < call.deadlineAt)
411
+ continue;
412
+ current.calls.delete(call);
413
+ replica.cancelWaiter();
414
+ call.reject(new StoreError('indeterminate', 'the transition did not install before its deadline'));
415
+ }
416
+ if (current.q !== null && current.calls.size === 0)
417
+ settleTransitions(null);
418
+ }
419
+ for (const [q, waiter] of [...outstanding]) {
420
+ // The request clock is for `act`, where an unanswered request's
421
+ // outcome is UNKNOWN: a snapshot still transferring here is a
422
+ // success in progress, and rejecting it reported failure for a
423
+ // success.
424
+ if (waiter.transition)
425
+ continue;
426
+ if (at - waiter.at < REQUEST_DEADLINE_MS)
427
+ continue;
428
+ outstanding.delete(q);
429
+ // Submitted and unanswered: the outcome is unknown, and saying
430
+ // so is the whole point. No resend.
431
+ waiter.reject(new StoreError('indeterminate', 'the store did not answer before the deadline'));
432
+ }
433
+ }, REQUEST_DEADLINE_MS);
434
+ /**
435
+ * Drive the replica's own clock.
436
+ *
437
+ * Nothing else does, and without it the assembly deadline never
438
+ * fires: a snapshot that loses one chunk waits for a chunk that is
439
+ * not coming, no `resync` is ever sent, and the caller's `ready()`
440
+ * simply times out. That is the shape a real browser produced — one
441
+ * dropped datagram, a 30-second wait — and it is why the deadline
442
+ * `assembly.ts` implements needs a hand here rather than a comment
443
+ * saying it exists.
444
+ *
445
+ * The interval is the assembly deadline's own resolution: checking
446
+ * more often would not make an abandoned assembly recover sooner,
447
+ * and checking less often would let it sit past its deadline.
448
+ */
449
+ const stopTick = schedule(() => {
450
+ if (closed)
451
+ return;
452
+ dispatch(framesOf(replica.tick()));
453
+ // The tick can produce a TERMINAL: a join nobody ever answered
454
+ // runs out of re-asks and fences itself. `settleReady` is what
455
+ // turns that into the caller's typed rejection instead of a
456
+ // `ready()` that never settles — the silence this module refuses
457
+ // everywhere else.
458
+ settleReady(core.getStatus().error ?? null);
459
+ }, TICK_INTERVAL_MS);
460
+ // A join carries the audience, and `encodeMessage` refuses an
461
+ // unrepresentable one with a bare `RangeError` — which every
462
+ // `error.code` branch in application code misses. Invalid data
463
+ // throws `StoreError` (#15), exactly like `act` and `input`.
464
+ let first;
465
+ try {
466
+ first = replica.join();
467
+ }
468
+ catch (error) {
469
+ throw new StoreError('invalid-data', 'the join request cannot be encoded', { cause: error });
470
+ }
471
+ // The join is a transition whose answer is its installation (§2):
472
+ // registered so a later equal-pending call can attach to it and be
473
+ // settled — or superseded — with it.
474
+ openTransition(null);
475
+ dispatch(framesOf([first]));
476
+ function correlate(q) {
477
+ if (outstanding.size >= MAX_OUTSTANDING) {
478
+ return Promise.reject(new StoreError('capacity', 'too many requests are already outstanding'));
479
+ }
480
+ return new Promise((resolve, reject) => {
481
+ outstanding.set(q, {
482
+ resolve: value => {
483
+ resolve(value);
484
+ },
485
+ reject,
486
+ at: now(),
487
+ transition: false,
488
+ });
489
+ });
490
+ }
491
+ const handle = {
492
+ getState: () => core.getState(),
493
+ subscribe: listener => core.subscribe(listener),
494
+ getStatus: () => core.getStatus(),
495
+ subscribeStatus: listener => core.subscribeStatus(listener),
496
+ ready: () => new Promise((resolve, reject) => {
497
+ if (closed) {
498
+ reject(new StoreError('closed', 'this store handle is closed'));
499
+ return;
500
+ }
501
+ if (replica.state === 'ready') {
502
+ resolve();
503
+ return;
504
+ }
505
+ // A TERMINAL replica answers now. `settleReady` only runs
506
+ // when a frame arrives, and nothing arrives for a store whose
507
+ // owner is gone — so a `ready()` called AFTER the goodbye
508
+ // landed was pushed onto a queue nobody would ever drain. A
509
+ // review probe found it by asking a terminal replica for
510
+ // `ready()` and waiting: the caller is left inferring the
511
+ // answer from silence, which is the defect this whole
512
+ // mechanism exists to remove.
513
+ if (replica.state === 'closed') {
514
+ reject(new StoreError('owner-lost', 'the store owner closed this handle'));
515
+ return;
516
+ }
517
+ readyWaiters.push({ resolve, reject });
518
+ }),
519
+ act: async (name, input) => {
520
+ if (closed)
521
+ throw new StoreError('closed', 'this store handle is closed');
522
+ // The OWNER is gone, which is not the same as "no handle yet":
523
+ // a terminal `owner-lost` discards the handle, and reporting
524
+ // that as `not-ready` would tell a caller to wait for a world
525
+ // that is never coming.
526
+ if (replica.state === 'closed') {
527
+ throw new StoreError('owner-lost', 'the store owner closed this handle');
528
+ }
529
+ const h = replica.handle;
530
+ if (h === null)
531
+ throw new StoreError('not-ready', 'no handle yet: the join has not been answered');
532
+ sequence += 1n;
533
+ const q = (options.newQ ?? (() => randomHex(8)))();
534
+ let frame;
535
+ try {
536
+ frame = encodeMessage({
537
+ k: 'act',
538
+ q,
539
+ h,
540
+ s: sequence.toString(),
541
+ name,
542
+ in: input,
543
+ });
544
+ }
545
+ catch (error) {
546
+ // `encodeMessage` refuses source values JSON would silently
547
+ // destroy (`NaN` as `null`, a dropped `undefined` key) with a
548
+ // bare `RangeError` — which every `error.code` branch in
549
+ // application code misses. Invalid data throws `StoreError`.
550
+ throw new StoreError('invalid-data', `the input for '${name}' cannot be encoded`, {
551
+ cause: error,
552
+ });
553
+ }
554
+ const answered = correlate(q);
555
+ // Observed now: a refusal that lands while `send` is still pending
556
+ // (a native mesh answers inside it) rejected `answered` with no
557
+ // handler yet, which is an unhandled rejection even though the
558
+ // caller receives it a moment later. As `setAudience` does.
559
+ answered.catch(() => undefined);
560
+ // The frame goes out AFTER the correlation is registered, or a
561
+ // reply that arrives immediately has nothing to resolve.
562
+ await send([frame]);
563
+ return (await answered);
564
+ },
565
+ input: (name, value) => {
566
+ if (closed)
567
+ return { type: 'dropped', reason: 'not-ready' };
568
+ const h = replica.handle;
569
+ if (h === null || replica.state !== 'ready')
570
+ return { type: 'dropped', reason: 'not-ready' };
571
+ // Monotone per name, and fire-and-forget: there is no promise to
572
+ // hand back and loss is not an error.
573
+ const next = (inputSequences.get(name) ?? 0n) + 1n;
574
+ inputSequences.set(name, next);
575
+ let frame;
576
+ try {
577
+ frame = encodeMessage({
578
+ k: 'in',
579
+ h,
580
+ s: next.toString(),
581
+ name,
582
+ in: value,
583
+ });
584
+ }
585
+ catch (error) {
586
+ // Same contract as `act`: invalid data throws `StoreError`.
587
+ throw new StoreError('invalid-data', `the input for '${name}' cannot be encoded`, {
588
+ cause: error,
589
+ });
590
+ }
591
+ dispatch([frame]);
592
+ return { type: next === 1n ? 'queued' : 'replaced' };
593
+ },
594
+ setAudience: async (names) => {
595
+ if (closed)
596
+ throw new StoreError('closed', 'this store handle is closed');
597
+ let requests;
598
+ try {
599
+ requests = replica.setAudience(names);
600
+ }
601
+ catch (error) {
602
+ // `encodeMessage` refuses an unrepresentable `aud` with a
603
+ // bare `RangeError` — which every `error.code` branch in
604
+ // application code misses. Invalid data throws `StoreError`
605
+ // (#15), exactly like `act` and `input`.
606
+ throw new StoreError('invalid-data', 'the audience cannot be encoded', { cause: error });
607
+ }
608
+ const slot = requests[0];
609
+ if (slot !== undefined) {
610
+ // A NEW transition: it takes the slot and supersedes the one
611
+ // in flight (#17/#6). An equal pending request takes the
612
+ // other branch and awaits THAT transition (§2: "an equal
613
+ // pending request awaits that transition").
614
+ const capacity = openTransition(slot.q);
615
+ if (capacity !== null)
616
+ throw capacity;
617
+ }
618
+ // The transition completes on the INSTALLATION, not on an
619
+ // acknowledgement: the manifest that answers it carries the same
620
+ // `q`, and the replica publishes when the last chunk lands. The
621
+ // caller is registered BEFORE the request goes out, or an
622
+ // installation that lands immediately finds nothing to settle.
623
+ const installed = awaitTransition();
624
+ // A send below can throw first; its caller's rejection must not
625
+ // leave `installed` to reject unobserved on a later turn.
626
+ installed.catch(() => undefined);
627
+ if (slot !== undefined)
628
+ await send(framesOf(requests));
629
+ await installed;
630
+ },
631
+ setInterest: async (keys) => {
632
+ if (closed)
633
+ throw new StoreError('closed', 'this store handle is closed');
634
+ const next = [...keys];
635
+ const placeholder = '0'.repeat(32);
636
+ let probe;
637
+ try {
638
+ probe = encodeMessage({ k: 'int', q: '0'.repeat(16), h: placeholder, int: next });
639
+ }
640
+ catch (error) {
641
+ throw new StoreError('invalid-data', 'the interest set cannot be encoded', { cause: error });
642
+ }
643
+ if (utf8Length(probe) > options.maxEventBytes) {
644
+ throw new StoreError('capacity', 'the interest set does not fit one message');
645
+ }
646
+ // Stated first: a join or resume from here on carries it.
647
+ replica.interest = next;
648
+ const h = replica.handle;
649
+ if (h === null || replica.state !== 'ready') {
650
+ // Not installed yet. The join in flight may carry the OLD set,
651
+ // so once ready, the new one is sent — unless it was superseded.
652
+ await handle.ready();
653
+ if (replica.interest === null || replica.interest.join('\u0000') !== next.join('\u0000'))
654
+ return;
655
+ return handle.setInterest(next);
656
+ }
657
+ const q = (options.newQ ?? (() => randomHex(8)))();
658
+ const answered = correlate(q);
659
+ answered.catch(() => undefined);
660
+ await send([encodeMessage({ k: 'int', q, h, int: next })]);
661
+ await answered;
662
+ },
663
+ reconnect: async () => {
664
+ if (closed)
665
+ throw new StoreError('closed', 'this store handle is closed');
666
+ // The old stream belongs to the lost session.
667
+ upstream = null;
668
+ opening = null;
669
+ let requests;
670
+ try {
671
+ requests = replica.reconnect();
672
+ }
673
+ catch (error) {
674
+ // The `aud`-carrying encode refuses an unrepresentable desired
675
+ // audience with a bare `RangeError`. Invalid data throws
676
+ // `StoreError` (#15), exactly like `act` and `input`.
677
+ throw new StoreError('invalid-data', 'the reconnect request cannot be encoded', {
678
+ cause: error,
679
+ });
680
+ }
681
+ // The resume takes the slot (§1.7b): a transition still in
682
+ // flight can never install now, so its callers are rejected
683
+ // with it (#17) rather than resolved over the resume's install.
684
+ if (requests[0] !== undefined)
685
+ openTransition(null);
686
+ await send(framesOf(requests));
687
+ },
688
+ close: async () => {
689
+ if (closed)
690
+ return;
691
+ closed = true;
692
+ stopAlive();
693
+ stopDeadlines();
694
+ stopTick();
695
+ const h = replica.handle;
696
+ if (h !== null) {
697
+ try {
698
+ const open = await stream();
699
+ await open.send(encoder.encode(encodeMessage({ k: 'leave', q: randomHex(8), h })));
700
+ }
701
+ catch {
702
+ // A `leave` is a courtesy: the lease expires the handle
703
+ // anyway, so a dead session is not an error here.
704
+ }
705
+ }
706
+ unsubscribe();
707
+ // The transition's callers first: a slot-less one (the
708
+ // construction join) has no `outstanding` entry to reject it
709
+ // from below.
710
+ settleTransitions(new StoreError('aborted', 'the store handle closed before this request was answered'));
711
+ for (const waiter of outstanding.values()) {
712
+ waiter.reject(new StoreError('aborted', 'the store handle closed before this request was answered'));
713
+ }
714
+ outstanding.clear();
715
+ for (const waiter of readyWaiters.splice(0, readyWaiters.length)) {
716
+ waiter.reject(new StoreError('closed', 'the store handle closed before a view was installed'));
717
+ }
718
+ upstream?.close();
719
+ upstream = null;
720
+ core.close();
721
+ },
722
+ };
723
+ return handle;
724
+ }