@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.
- package/CHANGELOG.md +517 -0
- package/LICENSE-APACHE +190 -0
- package/LICENSE-MIT +21 -0
- package/README.md +666 -0
- package/dist/async-queue.d.ts +26 -0
- package/dist/async-queue.js +62 -0
- package/dist/credential.d.ts +70 -0
- package/dist/credential.js +95 -0
- package/dist/errors.d.ts +400 -0
- package/dist/errors.js +726 -0
- package/dist/events.d.ts +232 -0
- package/dist/events.js +298 -0
- package/dist/identity.d.ts +40 -0
- package/dist/identity.js +78 -0
- package/dist/index.bundle.js +2 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +46 -0
- package/dist/leader/events.d.ts +111 -0
- package/dist/leader/events.js +116 -0
- package/dist/leader/index.d.ts +14 -0
- package/dist/leader/index.js +11 -0
- package/dist/leader/session.d.ts +539 -0
- package/dist/leader/session.js +770 -0
- package/dist/leader/wasm.d.ts +123 -0
- package/dist/leader/wasm.js +39 -0
- package/dist/lobby.d.ts +183 -0
- package/dist/lobby.js +504 -0
- package/dist/local.d.ts +80 -0
- package/dist/local.js +160 -0
- package/dist/net_leaf.d.ts +1494 -0
- package/dist/net_leaf.js +3753 -0
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +137 -0
- package/dist/netcode/client.d.ts +102 -0
- package/dist/netcode/client.js +296 -0
- package/dist/netcode/clock.d.ts +45 -0
- package/dist/netcode/clock.js +54 -0
- package/dist/netcode/host.d.ts +118 -0
- package/dist/netcode/host.js +276 -0
- package/dist/netcode/index.d.ts +19 -0
- package/dist/netcode/index.js +14 -0
- package/dist/netcode/interpolate.d.ts +50 -0
- package/dist/netcode/interpolate.js +100 -0
- package/dist/netcode/wire.d.ts +125 -0
- package/dist/netcode/wire.js +117 -0
- package/dist/node.d.ts +908 -0
- package/dist/node.js +1085 -0
- package/dist/org.d.ts +412 -0
- package/dist/org.js +567 -0
- package/dist/peer-driver.d.ts +117 -0
- package/dist/peer-driver.js +224 -0
- package/dist/store/assembly.d.ts +176 -0
- package/dist/store/assembly.js +325 -0
- package/dist/store/chunker.d.ts +83 -0
- package/dist/store/chunker.js +190 -0
- package/dist/store/core.d.ts +136 -0
- package/dist/store/core.js +477 -0
- package/dist/store/definition.d.ts +23 -0
- package/dist/store/definition.js +83 -0
- package/dist/store/errors.d.ts +77 -0
- package/dist/store/errors.js +94 -0
- package/dist/store/host.d.ts +247 -0
- package/dist/store/host.js +675 -0
- package/dist/store/index.d.ts +50 -0
- package/dist/store/index.js +40 -0
- package/dist/store/interest.d.ts +40 -0
- package/dist/store/interest.js +77 -0
- package/dist/store/inventory.d.ts +81 -0
- package/dist/store/inventory.js +140 -0
- package/dist/store/join.d.ts +118 -0
- package/dist/store/join.js +724 -0
- package/dist/store/json.d.ts +50 -0
- package/dist/store/json.js +283 -0
- package/dist/store/ledger.d.ts +187 -0
- package/dist/store/ledger.js +292 -0
- package/dist/store/owner.d.ts +616 -0
- package/dist/store/owner.js +1711 -0
- package/dist/store/patch.d.ts +70 -0
- package/dist/store/patch.js +166 -0
- package/dist/store/player.d.ts +65 -0
- package/dist/store/player.js +261 -0
- package/dist/store/replica.d.ts +187 -0
- package/dist/store/replica.js +740 -0
- package/dist/store/state.d.ts +42 -0
- package/dist/store/state.js +165 -0
- package/dist/store/types.d.ts +200 -0
- package/dist/store/types.js +25 -0
- package/dist/store/visibility.d.ts +118 -0
- package/dist/store/visibility.js +282 -0
- package/dist/store/wire.d.ts +309 -0
- package/dist/store/wire.js +834 -0
- package/dist/stream.d.ts +174 -0
- package/dist/stream.js +238 -0
- package/dist/three/index.d.ts +109 -0
- package/dist/three/index.js +130 -0
- package/dist/udp-probe.d.ts +159 -0
- package/dist/udp-probe.js +245 -0
- package/dist/wasm.d.ts +642 -0
- package/dist/wasm.js +98 -0
- package/dist/world/border.d.ts +84 -0
- package/dist/world/border.js +99 -0
- package/dist/world/handoff.d.ts +187 -0
- package/dist/world/handoff.js +222 -0
- package/dist/world/index.d.ts +13 -0
- package/dist/world/index.js +9 -0
- package/dist/world/link.d.ts +179 -0
- package/dist/world/link.js +476 -0
- package/dist/world/regions.d.ts +154 -0
- package/dist/world/regions.js +314 -0
- 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
|
+
}
|