@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,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot assembly, and what it refuses (brief §1.12, §2).
|
|
3
|
+
*
|
|
4
|
+
* The rules are short and every one of them is a refusal a partial
|
|
5
|
+
* snapshot would otherwise slip through:
|
|
6
|
+
*
|
|
7
|
+
* - `n ≥ 1`; every `i` in `0..n-1` present **exactly once**;
|
|
8
|
+
* - all chunks carry the same `(h, g, r, n)`;
|
|
9
|
+
* - the sum of decoded chunk lengths equals `bytes` **exactly**;
|
|
10
|
+
* - **the assembled document** — not each chunk envelope — is then
|
|
11
|
+
* JSON-parsed under the same depth and duplicate-key rules and
|
|
12
|
+
* validated by the definition's `state()` before anything is
|
|
13
|
+
* published.
|
|
14
|
+
*
|
|
15
|
+
* That last one is the distinction the earlier draft got wrong: the
|
|
16
|
+
* per-chunk checks validate the *envelopes*, not the snapshot. A
|
|
17
|
+
* document can be assembled from thirteen individually perfect
|
|
18
|
+
* envelopes and still be a duplicate-keyed depth bomb.
|
|
19
|
+
*
|
|
20
|
+
* Reclamation is equally explicit, because an assembly is the one
|
|
21
|
+
* place a replica holds a megabyte on a stranger's say-so: **one**
|
|
22
|
+
* in-flight per `(handle, generation)`, discarded on generation
|
|
23
|
+
* change, deadline and handle expiry, never merged into a later one,
|
|
24
|
+
* and bounded in total across handles so a peer cannot open many
|
|
25
|
+
* cheap assemblies to hold expensive memory.
|
|
26
|
+
*/
|
|
27
|
+
import { fromBase64 } from './chunker.js';
|
|
28
|
+
import { parseStoreJson } from './json.js';
|
|
29
|
+
import { isCanonicalBase64, MAX_SNAPSHOT_BYTES, MAX_SNAPSHOT_CHUNKS, } from './wire.js';
|
|
30
|
+
/** How long an assembly may stay open (brief §2). */
|
|
31
|
+
export const ASSEMBLY_DEADLINE_MS = 10_000;
|
|
32
|
+
/** Total in-flight assembly bytes one owner or replica may hold (§2). */
|
|
33
|
+
export const MAX_ASSEMBLY_BYTES_TOTAL = 8 * 1024 * 1024;
|
|
34
|
+
function reject(reason, fatal, code = 'invalid-data') {
|
|
35
|
+
return { ok: false, code, reason, fatal };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* One in-flight snapshot assembly.
|
|
39
|
+
*
|
|
40
|
+
* Holds the chunks it has admitted and nothing else: no partial
|
|
41
|
+
* document is exposed, and there is no accessor that would let a
|
|
42
|
+
* caller read half a world.
|
|
43
|
+
*/
|
|
44
|
+
export class Assembly {
|
|
45
|
+
manifest;
|
|
46
|
+
openedAt;
|
|
47
|
+
pieces;
|
|
48
|
+
encoded;
|
|
49
|
+
admitted = 0;
|
|
50
|
+
held = 0;
|
|
51
|
+
closed = false;
|
|
52
|
+
constructor(manifest,
|
|
53
|
+
/** When this assembly was opened, for the deadline. */
|
|
54
|
+
openedAt) {
|
|
55
|
+
this.manifest = manifest;
|
|
56
|
+
this.openedAt = openedAt;
|
|
57
|
+
this.pieces = new Array(manifest.n);
|
|
58
|
+
this.encoded = new Array(manifest.n);
|
|
59
|
+
}
|
|
60
|
+
/** Decoded bytes currently held. */
|
|
61
|
+
get bytesHeld() {
|
|
62
|
+
return this.held;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Bytes this assembly is COMMITTED to, for the total-bytes bound.
|
|
66
|
+
*
|
|
67
|
+
* The manifest's declared total, not what has arrived: an assembly
|
|
68
|
+
* that has admitted one chunk of twenty still intends to hold the
|
|
69
|
+
* whole snapshot, and a bound tested against arrivals lets every
|
|
70
|
+
* one of them in while each is still nearly empty. A reclaimed
|
|
71
|
+
* assembly is committed to nothing.
|
|
72
|
+
*/
|
|
73
|
+
get bytesReserved() {
|
|
74
|
+
return this.closed ? 0 : Math.max(Number(this.manifest.bytes), this.held);
|
|
75
|
+
}
|
|
76
|
+
/** How many distinct `i` have been admitted. */
|
|
77
|
+
get have() {
|
|
78
|
+
return this.admitted;
|
|
79
|
+
}
|
|
80
|
+
/** Whether this assembly has been reclaimed. */
|
|
81
|
+
get reclaimed() {
|
|
82
|
+
return this.closed;
|
|
83
|
+
}
|
|
84
|
+
/** Give up every chunk. A reclaimed assembly admits nothing. */
|
|
85
|
+
reclaim() {
|
|
86
|
+
this.closed = true;
|
|
87
|
+
this.pieces.fill(undefined);
|
|
88
|
+
this.encoded.fill(undefined);
|
|
89
|
+
this.admitted = 0;
|
|
90
|
+
this.held = 0;
|
|
91
|
+
}
|
|
92
|
+
/** Whether `now` is past this assembly's deadline. */
|
|
93
|
+
expired(now) {
|
|
94
|
+
return now - this.openedAt >= ASSEMBLY_DEADLINE_MS;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Admit one chunk.
|
|
98
|
+
*
|
|
99
|
+
* Returns the assembled document on the chunk that completes it, and
|
|
100
|
+
* **only** then: `done: false` carries a count, never a partial
|
|
101
|
+
* document.
|
|
102
|
+
*/
|
|
103
|
+
accept(chunk, validate, now) {
|
|
104
|
+
if (this.closed)
|
|
105
|
+
return reject('chunk-wrong-assembly', false);
|
|
106
|
+
if (this.expired(now)) {
|
|
107
|
+
this.reclaim();
|
|
108
|
+
return reject('deadline', true, 'timeout');
|
|
109
|
+
}
|
|
110
|
+
// All chunks carry the same `(h, g, r, n)`. A chunk from a
|
|
111
|
+
// restarted attempt at the same revision differs in `g`, which is
|
|
112
|
+
// what stops it joining this one.
|
|
113
|
+
const m = this.manifest;
|
|
114
|
+
if (chunk.h !== m.h || chunk.g !== m.g || chunk.r !== m.r || chunk.n !== m.n) {
|
|
115
|
+
return reject('chunk-wrong-assembly', false);
|
|
116
|
+
}
|
|
117
|
+
if (!Number.isInteger(chunk.i) || chunk.i < 0 || chunk.i >= m.n) {
|
|
118
|
+
return reject('chunk-index', false);
|
|
119
|
+
}
|
|
120
|
+
if (!isCanonicalBase64(chunk.d))
|
|
121
|
+
return reject('chunk-non-canonical', false);
|
|
122
|
+
const seen = this.encoded[chunk.i];
|
|
123
|
+
if (seen !== undefined) {
|
|
124
|
+
// Byte-identical retransmission is ordinary and idempotent.
|
|
125
|
+
if (seen === chunk.d)
|
|
126
|
+
return { ok: true, done: false, have: this.admitted };
|
|
127
|
+
// Two different payloads for one position: the assembly cannot
|
|
128
|
+
// decide which is the snapshot, so it is refused and restarted.
|
|
129
|
+
// Publishing either would publish a document neither peer sent.
|
|
130
|
+
this.reclaim();
|
|
131
|
+
return reject('chunk-conflict', true);
|
|
132
|
+
}
|
|
133
|
+
const bytes = fromBase64(chunk.d);
|
|
134
|
+
const declared = Number(m.bytes);
|
|
135
|
+
if (this.held + bytes.length > declared) {
|
|
136
|
+
// Overshooting the declared total is a mismatch discoverable
|
|
137
|
+
// now, before the memory is held.
|
|
138
|
+
this.reclaim();
|
|
139
|
+
return reject('byte-total-mismatch', true);
|
|
140
|
+
}
|
|
141
|
+
this.encoded[chunk.i] = chunk.d;
|
|
142
|
+
this.pieces[chunk.i] = bytes;
|
|
143
|
+
this.admitted += 1;
|
|
144
|
+
this.held += bytes.length;
|
|
145
|
+
if (this.admitted < m.n)
|
|
146
|
+
return { ok: true, done: false, have: this.admitted };
|
|
147
|
+
// Complete. Every `i` is present exactly once by construction: a
|
|
148
|
+
// repeat is either idempotent or a conflict, and the count only
|
|
149
|
+
// advances for a fresh index.
|
|
150
|
+
if (this.held !== declared) {
|
|
151
|
+
this.reclaim();
|
|
152
|
+
return reject('byte-total-mismatch', true);
|
|
153
|
+
}
|
|
154
|
+
const joined = new Uint8Array(this.held);
|
|
155
|
+
let offset = 0;
|
|
156
|
+
for (const piece of this.pieces) {
|
|
157
|
+
if (piece === undefined) {
|
|
158
|
+
// Unreachable while `admitted === n`, and asserted rather than
|
|
159
|
+
// assumed: publishing a short document would be worse than
|
|
160
|
+
// refusing one.
|
|
161
|
+
this.reclaim();
|
|
162
|
+
return reject('byte-total-mismatch', true);
|
|
163
|
+
}
|
|
164
|
+
joined.set(piece, offset);
|
|
165
|
+
offset += piece.length;
|
|
166
|
+
}
|
|
167
|
+
const text = new TextDecoder('utf-8', { fatal: true });
|
|
168
|
+
let document;
|
|
169
|
+
try {
|
|
170
|
+
// THE ASSEMBLED DOCUMENT, under the same rules a single frame
|
|
171
|
+
// gets: depth, duplicate keys, finite numbers. The per-chunk
|
|
172
|
+
// checks above validated envelopes, which is a different claim.
|
|
173
|
+
const parsed = parseStoreJson(text.decode(joined));
|
|
174
|
+
if (!parsed.ok) {
|
|
175
|
+
this.reclaim();
|
|
176
|
+
return reject('assembled-not-json', true);
|
|
177
|
+
}
|
|
178
|
+
document = parsed.value;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
this.reclaim();
|
|
182
|
+
return reject('assembled-not-json', true);
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
validate(document);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
this.reclaim();
|
|
189
|
+
return reject('assembled-invalid', true);
|
|
190
|
+
}
|
|
191
|
+
this.reclaim();
|
|
192
|
+
return { ok: true, done: true, document };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The assemblies one side holds, with the bounds that keep them from
|
|
197
|
+
* being a memory lever.
|
|
198
|
+
*
|
|
199
|
+
* One in-flight per `(handle, generation)` — a second manifest for the
|
|
200
|
+
* same pair replaces it rather than accumulating — and a total byte
|
|
201
|
+
* ceiling across handles, because a count bound alone is not a memory
|
|
202
|
+
* bound (§2).
|
|
203
|
+
*
|
|
204
|
+
* **The deadline is the HOLDER's to drive.** Nothing inside this class
|
|
205
|
+
* reads a clock: a host holding its own table MUST call {@link
|
|
206
|
+
* AssemblyTable.sweep} from its own. The store's own replica needs no
|
|
207
|
+
* such call — it enforces the assembly deadline in its `tick`, where
|
|
208
|
+
* an expired assembly is abandoned and re-asked for rather than
|
|
209
|
+
* silently dropped, and clears the table through `reclaimHandle`.
|
|
210
|
+
*/
|
|
211
|
+
export class AssemblyTable {
|
|
212
|
+
maxTotalBytes;
|
|
213
|
+
live = new Map();
|
|
214
|
+
constructor(maxTotalBytes = MAX_ASSEMBLY_BYTES_TOTAL) {
|
|
215
|
+
this.maxTotalBytes = maxTotalBytes;
|
|
216
|
+
}
|
|
217
|
+
static key(h, g) {
|
|
218
|
+
return `${h}/${g}`;
|
|
219
|
+
}
|
|
220
|
+
/** Bytes currently held across every open assembly. */
|
|
221
|
+
get bytesHeld() {
|
|
222
|
+
let total = 0;
|
|
223
|
+
for (const assembly of this.live.values())
|
|
224
|
+
total += assembly.bytesHeld;
|
|
225
|
+
return total;
|
|
226
|
+
}
|
|
227
|
+
/** Bytes every open assembly is committed to holding. */
|
|
228
|
+
get bytesReserved() {
|
|
229
|
+
let total = 0;
|
|
230
|
+
for (const assembly of this.live.values())
|
|
231
|
+
total += assembly.bytesReserved;
|
|
232
|
+
return total;
|
|
233
|
+
}
|
|
234
|
+
get size() {
|
|
235
|
+
return this.live.size;
|
|
236
|
+
}
|
|
237
|
+
/** The open assembly for a pair, if any. */
|
|
238
|
+
get(h, g) {
|
|
239
|
+
return this.live.get(AssemblyTable.key(h, g));
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Open an assembly for a manifest.
|
|
243
|
+
*
|
|
244
|
+
* Refuses the **newest** when the total would exceed the ceiling:
|
|
245
|
+
* the ones already in flight have a replica waiting on them, and
|
|
246
|
+
* dropping those to admit an arrival is how a peer evicts another's
|
|
247
|
+
* progress.
|
|
248
|
+
*/
|
|
249
|
+
open(manifest, now) {
|
|
250
|
+
if (!Number.isInteger(manifest.n) || manifest.n < 1 || manifest.n > MAX_SNAPSHOT_CHUNKS) {
|
|
251
|
+
return reject('manifest-chunk-count', true);
|
|
252
|
+
}
|
|
253
|
+
const declared = Number(manifest.bytes);
|
|
254
|
+
if (!Number.isSafeInteger(declared) || declared < 0 || declared > MAX_SNAPSHOT_BYTES) {
|
|
255
|
+
return reject('manifest-bytes', true, 'capacity');
|
|
256
|
+
}
|
|
257
|
+
const key = AssemblyTable.key(manifest.h, manifest.g);
|
|
258
|
+
// Against RESERVATIONS, not arrivals. Testing the ceiling against
|
|
259
|
+
// bytes already admitted admitted every concurrent manifest —
|
|
260
|
+
// each one measured while the others were still empty — and the
|
|
261
|
+
// table then filled to many times its bound. The reservation is
|
|
262
|
+
// released when the assembly is reclaimed, completed or swept.
|
|
263
|
+
//
|
|
264
|
+
// Checked BEFORE the predecessor is touched, with its reservation
|
|
265
|
+
// excluded from the sum: a replacement for the same pair counts
|
|
266
|
+
// without its predecessor, but a refusal must not be destructive —
|
|
267
|
+
// reclaiming first meant `assembly-too-large` destroyed the very
|
|
268
|
+
// assembly it then refused to replace.
|
|
269
|
+
const predecessor = this.live.get(key);
|
|
270
|
+
const replaced = predecessor?.bytesReserved ?? 0;
|
|
271
|
+
if (this.bytesReserved - replaced + declared > this.maxTotalBytes) {
|
|
272
|
+
return reject('assembly-too-large', true, 'capacity');
|
|
273
|
+
}
|
|
274
|
+
// One in-flight per pair. A replacement manifest for the same pair
|
|
275
|
+
// reclaims the predecessor rather than leaving it to a deadline.
|
|
276
|
+
predecessor?.reclaim();
|
|
277
|
+
this.live.delete(key);
|
|
278
|
+
const assembly = new Assembly(manifest, now);
|
|
279
|
+
this.live.set(key, assembly);
|
|
280
|
+
return assembly;
|
|
281
|
+
}
|
|
282
|
+
/** Reclaim one pair's assembly. */
|
|
283
|
+
reclaim(h, g) {
|
|
284
|
+
const key = AssemblyTable.key(h, g);
|
|
285
|
+
this.live.get(key)?.reclaim();
|
|
286
|
+
this.live.delete(key);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Reclaim every assembly for a handle — a generation change, a
|
|
290
|
+
* handle expiry, a session loss.
|
|
291
|
+
*/
|
|
292
|
+
reclaimHandle(h) {
|
|
293
|
+
for (const [key, assembly] of [...this.live]) {
|
|
294
|
+
if (key.startsWith(`${h}/`)) {
|
|
295
|
+
assembly.reclaim();
|
|
296
|
+
this.live.delete(key);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Reclaim everything past its deadline. Returns how many went.
|
|
302
|
+
*
|
|
303
|
+
* The one deadline enforcer for a table this module does not drive:
|
|
304
|
+
* a host holding its own `AssemblyTable` must call this from its own
|
|
305
|
+
* clock (see the class note), or its assemblies hold their
|
|
306
|
+
* reservations for ever.
|
|
307
|
+
*/
|
|
308
|
+
sweep(now) {
|
|
309
|
+
let swept = 0;
|
|
310
|
+
for (const [key, assembly] of [...this.live]) {
|
|
311
|
+
if (assembly.expired(now)) {
|
|
312
|
+
assembly.reclaim();
|
|
313
|
+
this.live.delete(key);
|
|
314
|
+
swept += 1;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return swept;
|
|
318
|
+
}
|
|
319
|
+
/** Give up everything. */
|
|
320
|
+
clear() {
|
|
321
|
+
for (const assembly of this.live.values())
|
|
322
|
+
assembly.reclaim();
|
|
323
|
+
this.live.clear();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot chunking (brief §1.9).
|
|
3
|
+
*
|
|
4
|
+
* The owner serializes the **projected** state to UTF-8 JSON, splits
|
|
5
|
+
* the **bytes** into `n` pieces, and sends `i = 0..n-1`. Splitting
|
|
6
|
+
* bytes rather than characters is the whole reason `d` is base64: a
|
|
7
|
+
* UTF-8 code point can straddle any byte boundary, and a raw-text
|
|
8
|
+
* chunk containing half of one is not valid JSON string content. The
|
|
9
|
+
* +33 % base64 cost is accounted for in the budget rather than
|
|
10
|
+
* discovered by an oversized frame.
|
|
11
|
+
*
|
|
12
|
+
* **The budget is derived at runtime, never hardcoded.** It comes from
|
|
13
|
+
* the wire's own `maxEventBytes()` minus the `snap` envelope's
|
|
14
|
+
* reserve, so a transport whose cap changes moves the chunk size with
|
|
15
|
+
* it. A store that guessed this number would be wrong on the first
|
|
16
|
+
* transport that changed, and would be wrong *silently* — by emitting
|
|
17
|
+
* a frame the far side refuses.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Room left for everything in a `snap` that is not `d`.
|
|
21
|
+
*
|
|
22
|
+
* 192, against a **measured worst case of 134 B** with every field at
|
|
23
|
+
* its maximum spelling: 20-digit `u64` decimals, a 32-hex handle, and
|
|
24
|
+
* `i`/`n` at 255. So it is a bound, not a sample of one message.
|
|
25
|
+
*/
|
|
26
|
+
export declare const SNAP_ENVELOPE_RESERVE = 192;
|
|
27
|
+
/**
|
|
28
|
+
* The floor a store refuses to start below (brief §1.9).
|
|
29
|
+
*
|
|
30
|
+
* Not an aesthetic minimum: at smaller chunk sizes a snapshot of any
|
|
31
|
+
* useful size exceeds {@link MAX_SNAPSHOT_CHUNKS}, so the store would
|
|
32
|
+
* start and then fail on its first projection.
|
|
33
|
+
*/
|
|
34
|
+
export declare const MIN_CHUNK_BYTES = 1024;
|
|
35
|
+
/** The largest `man`/`delta` envelope, for the startup check (§1.9). */
|
|
36
|
+
export declare const LARGEST_FROZEN_ENVELOPE = 197;
|
|
37
|
+
/**
|
|
38
|
+
* Raw bytes per chunk, for a transport whose event cap is
|
|
39
|
+
* `maxEventBytes`.
|
|
40
|
+
*
|
|
41
|
+
* `/4 * 3` because base64 emits 4 characters per 3 bytes, and the
|
|
42
|
+
* frame carries the encoded form: the division is on the *encoded*
|
|
43
|
+
* budget and the multiplication converts it back to raw input.
|
|
44
|
+
*/
|
|
45
|
+
export declare function chunkBytesFor(maxEventBytes: number): number;
|
|
46
|
+
/**
|
|
47
|
+
* Refuse to start rather than discover the numbers later (§1.9).
|
|
48
|
+
*
|
|
49
|
+
* Throws a typed `capacity` error **naming both numbers**, because
|
|
50
|
+
* "the store would not start" is only actionable if it says what did
|
|
51
|
+
* not fit.
|
|
52
|
+
*/
|
|
53
|
+
export declare function assertChunkingFits(maxEventBytes: number): number;
|
|
54
|
+
/** One snapshot, split and ready to emit. */
|
|
55
|
+
export interface Chunked {
|
|
56
|
+
/** The revision-independent byte total the manifest declares. */
|
|
57
|
+
readonly bytes: number;
|
|
58
|
+
/** How many pieces; `n` on the wire. */
|
|
59
|
+
readonly n: number;
|
|
60
|
+
/** Canonical base64 pieces, in `i` order. */
|
|
61
|
+
readonly pieces: readonly string[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Split a projected state for emission.
|
|
65
|
+
*
|
|
66
|
+
* Refuses rather than truncates: a snapshot that does not fit the
|
|
67
|
+
* declared bounds is a `capacity` error at the owner, before anything
|
|
68
|
+
* is sent, because half a world is not a smaller world.
|
|
69
|
+
*/
|
|
70
|
+
export declare function chunkSnapshot(state: unknown, chunkBytes: number): Chunked;
|
|
71
|
+
/** Canonical base64: standard alphabet, padded, no line breaks. */
|
|
72
|
+
export declare function base64(bytes: Uint8Array): string;
|
|
73
|
+
/**
|
|
74
|
+
* The inverse of {@link base64}.
|
|
75
|
+
*
|
|
76
|
+
* Canonicality is the CALLER's gate: `isCanonicalBase64` runs in the
|
|
77
|
+
* wire decoder and again in `Assembly.accept`. This only decodes —
|
|
78
|
+
* `atob` accepts non-canonical spellings, so a direct caller MUST run
|
|
79
|
+
* that check first.
|
|
80
|
+
*/
|
|
81
|
+
export declare function fromBase64(text: string): Uint8Array;
|
|
82
|
+
/** The UTF-8 byte length of a JSON document, for a budget check. */
|
|
83
|
+
export declare function jsonByteLength(value: unknown): number;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot chunking (brief §1.9).
|
|
3
|
+
*
|
|
4
|
+
* The owner serializes the **projected** state to UTF-8 JSON, splits
|
|
5
|
+
* the **bytes** into `n` pieces, and sends `i = 0..n-1`. Splitting
|
|
6
|
+
* bytes rather than characters is the whole reason `d` is base64: a
|
|
7
|
+
* UTF-8 code point can straddle any byte boundary, and a raw-text
|
|
8
|
+
* chunk containing half of one is not valid JSON string content. The
|
|
9
|
+
* +33 % base64 cost is accounted for in the budget rather than
|
|
10
|
+
* discovered by an oversized frame.
|
|
11
|
+
*
|
|
12
|
+
* **The budget is derived at runtime, never hardcoded.** It comes from
|
|
13
|
+
* the wire's own `maxEventBytes()` minus the `snap` envelope's
|
|
14
|
+
* reserve, so a transport whose cap changes moves the chunk size with
|
|
15
|
+
* it. A store that guessed this number would be wrong on the first
|
|
16
|
+
* transport that changed, and would be wrong *silently* — by emitting
|
|
17
|
+
* a frame the far side refuses.
|
|
18
|
+
*/
|
|
19
|
+
import { StoreError } from './errors.js';
|
|
20
|
+
import { inadmissibleValue, MAX_SNAPSHOT_BYTES, MAX_SNAPSHOT_CHUNKS, utf8Length } from './wire.js';
|
|
21
|
+
/**
|
|
22
|
+
* Room left for everything in a `snap` that is not `d`.
|
|
23
|
+
*
|
|
24
|
+
* 192, against a **measured worst case of 134 B** with every field at
|
|
25
|
+
* its maximum spelling: 20-digit `u64` decimals, a 32-hex handle, and
|
|
26
|
+
* `i`/`n` at 255. So it is a bound, not a sample of one message.
|
|
27
|
+
*/
|
|
28
|
+
export const SNAP_ENVELOPE_RESERVE = 192;
|
|
29
|
+
/**
|
|
30
|
+
* The floor a store refuses to start below (brief §1.9).
|
|
31
|
+
*
|
|
32
|
+
* Not an aesthetic minimum: at smaller chunk sizes a snapshot of any
|
|
33
|
+
* useful size exceeds {@link MAX_SNAPSHOT_CHUNKS}, so the store would
|
|
34
|
+
* start and then fail on its first projection.
|
|
35
|
+
*/
|
|
36
|
+
export const MIN_CHUNK_BYTES = 1024;
|
|
37
|
+
/** The largest `man`/`delta` envelope, for the startup check (§1.9). */
|
|
38
|
+
export const LARGEST_FROZEN_ENVELOPE = 197;
|
|
39
|
+
/**
|
|
40
|
+
* Raw bytes per chunk, for a transport whose event cap is
|
|
41
|
+
* `maxEventBytes`.
|
|
42
|
+
*
|
|
43
|
+
* `/4 * 3` because base64 emits 4 characters per 3 bytes, and the
|
|
44
|
+
* frame carries the encoded form: the division is on the *encoded*
|
|
45
|
+
* budget and the multiplication converts it back to raw input.
|
|
46
|
+
*/
|
|
47
|
+
export function chunkBytesFor(maxEventBytes) {
|
|
48
|
+
return Math.floor((maxEventBytes - SNAP_ENVELOPE_RESERVE) / 4) * 3;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Refuse to start rather than discover the numbers later (§1.9).
|
|
52
|
+
*
|
|
53
|
+
* Throws a typed `capacity` error **naming both numbers**, because
|
|
54
|
+
* "the store would not start" is only actionable if it says what did
|
|
55
|
+
* not fit.
|
|
56
|
+
*/
|
|
57
|
+
export function assertChunkingFits(maxEventBytes) {
|
|
58
|
+
if (!Number.isFinite(maxEventBytes) || !Number.isInteger(maxEventBytes) || maxEventBytes <= 0) {
|
|
59
|
+
throw new StoreError('capacity', `the transport reported no usable event size (${String(maxEventBytes)})`);
|
|
60
|
+
}
|
|
61
|
+
if (LARGEST_FROZEN_ENVELOPE > maxEventBytes) {
|
|
62
|
+
throw new StoreError('capacity', `the frozen envelopes need ${LARGEST_FROZEN_ENVELOPE} B and the transport allows ${maxEventBytes} B`);
|
|
63
|
+
}
|
|
64
|
+
const chunkBytes = chunkBytesFor(maxEventBytes);
|
|
65
|
+
if (chunkBytes < MIN_CHUNK_BYTES) {
|
|
66
|
+
throw new StoreError('capacity', `a ${maxEventBytes} B event leaves ${chunkBytes} B per chunk, below the ${MIN_CHUNK_BYTES} B floor`);
|
|
67
|
+
}
|
|
68
|
+
// The product, not just the floor: chunks that meet MIN_CHUNK_BYTES
|
|
69
|
+
// can still be unable to carry MAX_SNAPSHOT_BYTES within
|
|
70
|
+
// MAX_SNAPSHOT_CHUNKS, and the store would start and then discover
|
|
71
|
+
// that on its first large projection — the exact opposite of the
|
|
72
|
+
// promise above.
|
|
73
|
+
if (chunkBytes * MAX_SNAPSHOT_CHUNKS < MAX_SNAPSHOT_BYTES) {
|
|
74
|
+
throw new StoreError('capacity', `a ${maxEventBytes} B event leaves ${chunkBytes} B per chunk: ${MAX_SNAPSHOT_CHUNKS} of them cover ` +
|
|
75
|
+
`${chunkBytes * MAX_SNAPSHOT_CHUNKS} B, below the ${MAX_SNAPSHOT_BYTES} B snapshot bound`);
|
|
76
|
+
}
|
|
77
|
+
return chunkBytes;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Split a projected state for emission.
|
|
81
|
+
*
|
|
82
|
+
* Refuses rather than truncates: a snapshot that does not fit the
|
|
83
|
+
* declared bounds is a `capacity` error at the owner, before anything
|
|
84
|
+
* is sent, because half a world is not a smaller world.
|
|
85
|
+
*/
|
|
86
|
+
export function chunkSnapshot(state, chunkBytes) {
|
|
87
|
+
if (chunkBytes < 1) {
|
|
88
|
+
throw new StoreError('capacity', `a chunk budget of ${chunkBytes} B cannot carry anything`);
|
|
89
|
+
}
|
|
90
|
+
// The admissible-value scan, over the SOURCE values like every
|
|
91
|
+
// other value-bearing payload — and BEFORE serialization, because
|
|
92
|
+
// `JSON.stringify` is lossy, silent and throwing: `NaN`/`Infinity`
|
|
93
|
+
// become `null`, an `undefined`-valued own key disappears, a
|
|
94
|
+
// `toJSON`/`Map`/symbol-keyed value is transformed or emptied, and
|
|
95
|
+
// a cycle or a `BigInt` raises a bare `TypeError` out of the
|
|
96
|
+
// serializer. Running the scan on the wrong side of `stringify`
|
|
97
|
+
// either saw transformed evidence or never ran at all — and the
|
|
98
|
+
// delta-overflow fallback is exactly this path.
|
|
99
|
+
try {
|
|
100
|
+
const bad = inadmissibleValue(state, 'state');
|
|
101
|
+
if (bad !== null) {
|
|
102
|
+
throw new StoreError('invalid-data', `the projected state is not JSON: ${bad}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error instanceof StoreError)
|
|
107
|
+
throw error;
|
|
108
|
+
// The scan itself can throw — a hostile getter — and an escape
|
|
109
|
+
// here is `invalid-data` like every other refusal on this path.
|
|
110
|
+
throw new StoreError('invalid-data', `the projected state is not JSON: ${String(error)}`, {
|
|
111
|
+
cause: error,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
let json;
|
|
115
|
+
try {
|
|
116
|
+
json = JSON.stringify(state);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
// Everything named above is refused by the scan first, so what
|
|
120
|
+
// escapes here is `stringify` throwing on something the scan
|
|
121
|
+
// cannot see (a getter that throws mid-serialization). Still
|
|
122
|
+
// invalid data, never a bare `TypeError`.
|
|
123
|
+
throw new StoreError('invalid-data', 'the projected state is not JSON', { cause: error });
|
|
124
|
+
}
|
|
125
|
+
if (json === undefined) {
|
|
126
|
+
// `JSON.stringify` returns undefined for a function or a symbol.
|
|
127
|
+
// Serializing that as the string "undefined" would ship a snapshot
|
|
128
|
+
// the replica cannot parse.
|
|
129
|
+
throw new StoreError('invalid-data', 'the projected state is not JSON');
|
|
130
|
+
}
|
|
131
|
+
const bytes = new TextEncoder().encode(json);
|
|
132
|
+
if (bytes.length > MAX_SNAPSHOT_BYTES) {
|
|
133
|
+
throw new StoreError('capacity', `the projection is ${bytes.length} B and the bound is ${MAX_SNAPSHOT_BYTES} B`);
|
|
134
|
+
}
|
|
135
|
+
const pieces = [];
|
|
136
|
+
// `n >= 1` holds because `JSON.stringify` never returns the empty
|
|
137
|
+
// string — the smallest document is `{}` or `null`, two bytes. That
|
|
138
|
+
// matters: `n = 0` would make "every `i` in `0..n-1` present exactly
|
|
139
|
+
// once" vacuously true, so an assembly could complete having
|
|
140
|
+
// received nothing. It is a consequence here rather than a guard,
|
|
141
|
+
// and a guard that cannot fire is not a defence.
|
|
142
|
+
for (let offset = 0; offset < bytes.length; offset += chunkBytes) {
|
|
143
|
+
pieces.push(base64(bytes.subarray(offset, offset + chunkBytes)));
|
|
144
|
+
}
|
|
145
|
+
if (pieces.length > MAX_SNAPSHOT_CHUNKS) {
|
|
146
|
+
throw new StoreError('capacity', `the projection needs ${pieces.length} chunks and the bound is ${MAX_SNAPSHOT_CHUNKS}`);
|
|
147
|
+
}
|
|
148
|
+
return { bytes: bytes.length, n: pieces.length, pieces };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Chars per bulk conversion step.
|
|
152
|
+
*
|
|
153
|
+
* `btoa`/`atob` traffic in binary strings, and building one a byte at
|
|
154
|
+
* a time is a call per byte over up to a megabyte — against "never
|
|
155
|
+
* avoidably allocate, copy, or compute". Conversion runs in bounded
|
|
156
|
+
* pieces; 0x8000 stays under every engine's argument-count bound.
|
|
157
|
+
*/
|
|
158
|
+
const CONVERSION_CHARS = 0x8000;
|
|
159
|
+
/** Canonical base64: standard alphabet, padded, no line breaks. */
|
|
160
|
+
export function base64(bytes) {
|
|
161
|
+
let binary = '';
|
|
162
|
+
for (let offset = 0; offset < bytes.length; offset += CONVERSION_CHARS) {
|
|
163
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + CONVERSION_CHARS));
|
|
164
|
+
}
|
|
165
|
+
return btoa(binary);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The inverse of {@link base64}.
|
|
169
|
+
*
|
|
170
|
+
* Canonicality is the CALLER's gate: `isCanonicalBase64` runs in the
|
|
171
|
+
* wire decoder and again in `Assembly.accept`. This only decodes —
|
|
172
|
+
* `atob` accepts non-canonical spellings, so a direct caller MUST run
|
|
173
|
+
* that check first.
|
|
174
|
+
*/
|
|
175
|
+
export function fromBase64(text) {
|
|
176
|
+
const binary = atob(text);
|
|
177
|
+
// Straight into the destination: there is no bulk binary-string →
|
|
178
|
+
// bytes primitive to chunk here, and no intermediate is built.
|
|
179
|
+
const out = new Uint8Array(binary.length);
|
|
180
|
+
for (let i = 0; i < binary.length; i += 1)
|
|
181
|
+
out[i] = binary.charCodeAt(i);
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/** The UTF-8 byte length of a JSON document, for a budget check. */
|
|
185
|
+
export function jsonByteLength(value) {
|
|
186
|
+
const json = JSON.stringify(value);
|
|
187
|
+
if (json === undefined)
|
|
188
|
+
throw new StoreError('invalid-data', 'not JSON');
|
|
189
|
+
return utf8Length(json);
|
|
190
|
+
}
|