@net-mesh/browser 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +517 -0
  2. package/LICENSE-APACHE +190 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +666 -0
  5. package/dist/async-queue.d.ts +26 -0
  6. package/dist/async-queue.js +62 -0
  7. package/dist/credential.d.ts +70 -0
  8. package/dist/credential.js +95 -0
  9. package/dist/errors.d.ts +400 -0
  10. package/dist/errors.js +726 -0
  11. package/dist/events.d.ts +232 -0
  12. package/dist/events.js +298 -0
  13. package/dist/identity.d.ts +40 -0
  14. package/dist/identity.js +78 -0
  15. package/dist/index.bundle.js +2 -0
  16. package/dist/index.d.ts +38 -0
  17. package/dist/index.js +46 -0
  18. package/dist/leader/events.d.ts +111 -0
  19. package/dist/leader/events.js +116 -0
  20. package/dist/leader/index.d.ts +14 -0
  21. package/dist/leader/index.js +11 -0
  22. package/dist/leader/session.d.ts +539 -0
  23. package/dist/leader/session.js +770 -0
  24. package/dist/leader/wasm.d.ts +123 -0
  25. package/dist/leader/wasm.js +39 -0
  26. package/dist/lobby.d.ts +183 -0
  27. package/dist/lobby.js +504 -0
  28. package/dist/local.d.ts +80 -0
  29. package/dist/local.js +160 -0
  30. package/dist/net_leaf.d.ts +1494 -0
  31. package/dist/net_leaf.js +3753 -0
  32. package/dist/net_leaf_bg.wasm +0 -0
  33. package/dist/net_leaf_bg.wasm.d.ts +137 -0
  34. package/dist/netcode/client.d.ts +102 -0
  35. package/dist/netcode/client.js +296 -0
  36. package/dist/netcode/clock.d.ts +45 -0
  37. package/dist/netcode/clock.js +54 -0
  38. package/dist/netcode/host.d.ts +118 -0
  39. package/dist/netcode/host.js +276 -0
  40. package/dist/netcode/index.d.ts +19 -0
  41. package/dist/netcode/index.js +14 -0
  42. package/dist/netcode/interpolate.d.ts +50 -0
  43. package/dist/netcode/interpolate.js +100 -0
  44. package/dist/netcode/wire.d.ts +125 -0
  45. package/dist/netcode/wire.js +117 -0
  46. package/dist/node.d.ts +908 -0
  47. package/dist/node.js +1085 -0
  48. package/dist/org.d.ts +412 -0
  49. package/dist/org.js +567 -0
  50. package/dist/peer-driver.d.ts +117 -0
  51. package/dist/peer-driver.js +224 -0
  52. package/dist/store/assembly.d.ts +176 -0
  53. package/dist/store/assembly.js +325 -0
  54. package/dist/store/chunker.d.ts +83 -0
  55. package/dist/store/chunker.js +190 -0
  56. package/dist/store/core.d.ts +136 -0
  57. package/dist/store/core.js +477 -0
  58. package/dist/store/definition.d.ts +23 -0
  59. package/dist/store/definition.js +83 -0
  60. package/dist/store/errors.d.ts +77 -0
  61. package/dist/store/errors.js +94 -0
  62. package/dist/store/host.d.ts +247 -0
  63. package/dist/store/host.js +675 -0
  64. package/dist/store/index.d.ts +50 -0
  65. package/dist/store/index.js +40 -0
  66. package/dist/store/interest.d.ts +40 -0
  67. package/dist/store/interest.js +77 -0
  68. package/dist/store/inventory.d.ts +81 -0
  69. package/dist/store/inventory.js +140 -0
  70. package/dist/store/join.d.ts +118 -0
  71. package/dist/store/join.js +724 -0
  72. package/dist/store/json.d.ts +50 -0
  73. package/dist/store/json.js +283 -0
  74. package/dist/store/ledger.d.ts +187 -0
  75. package/dist/store/ledger.js +292 -0
  76. package/dist/store/owner.d.ts +616 -0
  77. package/dist/store/owner.js +1711 -0
  78. package/dist/store/patch.d.ts +70 -0
  79. package/dist/store/patch.js +166 -0
  80. package/dist/store/player.d.ts +65 -0
  81. package/dist/store/player.js +261 -0
  82. package/dist/store/replica.d.ts +187 -0
  83. package/dist/store/replica.js +740 -0
  84. package/dist/store/state.d.ts +42 -0
  85. package/dist/store/state.js +165 -0
  86. package/dist/store/types.d.ts +200 -0
  87. package/dist/store/types.js +25 -0
  88. package/dist/store/visibility.d.ts +118 -0
  89. package/dist/store/visibility.js +282 -0
  90. package/dist/store/wire.d.ts +309 -0
  91. package/dist/store/wire.js +834 -0
  92. package/dist/stream.d.ts +174 -0
  93. package/dist/stream.js +238 -0
  94. package/dist/three/index.d.ts +109 -0
  95. package/dist/three/index.js +130 -0
  96. package/dist/udp-probe.d.ts +159 -0
  97. package/dist/udp-probe.js +245 -0
  98. package/dist/wasm.d.ts +642 -0
  99. package/dist/wasm.js +98 -0
  100. package/dist/world/border.d.ts +84 -0
  101. package/dist/world/border.js +99 -0
  102. package/dist/world/handoff.d.ts +187 -0
  103. package/dist/world/handoff.js +222 -0
  104. package/dist/world/index.d.ts +13 -0
  105. package/dist/world/index.js +9 -0
  106. package/dist/world/link.d.ts +179 -0
  107. package/dist/world/link.js +476 -0
  108. package/dist/world/regions.d.ts +154 -0
  109. package/dist/world/regions.js +314 -0
  110. package/package.json +72 -0
@@ -0,0 +1,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
+ }