@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,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
|
+
}
|