@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
package/CHANGELOG.md ADDED
@@ -0,0 +1,517 @@
1
+ # Changelog
2
+
3
+ Notable changes to `@net-mesh/browser`.
4
+
5
+ This file records what a page has to do differently. The full
6
+ per-release story for the whole system lives in the crate's release
7
+ notes (`net/crates/net/docs/releases/`); this is the subset that
8
+ reaches this package's public surface.
9
+
10
+ `@net-mesh/browser` and `net-mesh-leaf` (the wasm) are released
11
+ together and must be upgraded together: the package declares the
12
+ wasm-bindgen boundary in `src/wasm.ts`, so a version skew shows up as a
13
+ missing method at the call site rather than at install time. Unlike
14
+ `@net-mesh/sdk`, this package never depends on `@net-mesh/core` — see
15
+ the README on why it is a sibling package rather than a sub-path.
16
+
17
+ ## 0.37.0 — the first npm release
18
+
19
+ Everything in this file ships in 0.37.0, the package's first release on npm.
20
+ This section is what landed last; the section after it is the initial surface.
21
+
22
+ ### Added
23
+
24
+ - **`@net-mesh/browser/world`: at-most-once entity handoff between region
25
+ hosts** (plan §9). Pure steps over a region's persisted state: `beginHandoff`
26
+ freezes an entity and offers it; `onHandoffOffer` decides each handoff id
27
+ once and answers every repeat the same way; `onHandoffReply` settles, and
28
+ only an explicit refusal brings the entity back; `retryHandoffs` re-offers
29
+ and, after `giveUpMs`, reports `unresolved` while keeping it frozen;
30
+ `reofferHandoff` resumes it; `locate` answers where an entity is.
31
+ **Contract:** make each step's state durable before sending its messages.
32
+ A deterministic simulation (300 seeds, with loss, duplication, reordering
33
+ and host crashes) finds no entity ever live twice and no id admitted twice.
34
+ - **Over the mesh:** `handoffLink({ transport, label, peerOf })` carries the
35
+ messages. It accepts a message naming region X only from the node that
36
+ hosts X, so a player cannot forge an offer or a reply.
37
+ `regionHandoffs({ link, region, load, commit, persist, … })` runs the
38
+ steps in order: commit, `persist` (awaited), then send.
39
+ `storeRegion(host, { collection, ledger })` keeps a region's entities and
40
+ its handoff ledger in one store document, so `persistStore` saves both
41
+ together; `parseHandoffLedger` validates the ledger. Tested between two
42
+ hosts on the local mesh and between two native `MeshNode` hosts
43
+ persisting to RedEX.
44
+ - **Cross-border actions:** `regionHandoffs({ actions })` serves named
45
+ actions, and `handoffs.forward(to, name, input)` asks a neighbour. The
46
+ neighbour runs each action id once, records the outcome durably with its
47
+ effect, and answers every repeat the same way. A forward rejects with
48
+ `BorderActionError` `refused` (the neighbour said no) or `unresolved`
49
+ (no answer: it may have run, never twice). A simulation over 300 seeds
50
+ with loss, duplication and crashes finds no action applied twice.
51
+ - **No pop at borders:** `joinWorld`'s `lingerMs` (default 500) keeps an
52
+ entity that vanished from a held region at its last state until it
53
+ appears elsewhere. A handoff freezes it at the source before the
54
+ destination shows it. `handoffLink`'s `refresh` (e.g.
55
+ `directory.lookup`) re-reads the directory when a destination is unknown
56
+ or a sender does not match it.
57
+ - **Ghosting:** `regionHandoffs({ ghosting })` mirrors a region's
58
+ entities near a border to the neighbour across it, read-only. Read them
59
+ with `handoffs.ghosts()` / `onGhosts`. A newer frame replaces an older
60
+ one, an empty frame clears, and a silent neighbour's ghosts expire.
61
+ `ghostTargets` names which neighbours an entity at a position reaches.
62
+ - **Regions and a player's view:** `regionOf` / `regionsAround`;
63
+ `announceRegions(node, world, regions)`, which re-announces every 2 s;
64
+ `regionDirectory({ node, world, trustedHosts })`, where a region more
65
+ than one untrusted node claims is `ambiguous` and never guessed; and
66
+ `joinWorld(...)`, which keeps replicas of the 3×3 regions around the
67
+ player (released beyond 5×5), merges them into one `bindEntities`-ready
68
+ view, and sends `act` to the player's region.
69
+
70
+ - **Netcode: interest keys.** `hostNetcode({ interest: (id, entity) => key })`
71
+ and `joinNetcode({ interest: keys })` / `setInterest(keys)`: a player
72
+ receives only the entities under the keys it named. An entity keyed `null`
73
+ is always delivered. `visible` stays the permission, and interest only
74
+ narrows it. The key set crosses the lossy carrier as a versioned frame,
75
+ repeated until a snapshot echoes the version back.
76
+ - **Netcode: large snapshots travel as chunks.** A snapshot over the host's
77
+ `maxFrameBytes` (default 8000) is split into chunks that each fit one
78
+ event, with each entity always in the same chunk. A lost chunk no longer
79
+ loses the whole snapshot: its entities are carried over from the previous
80
+ snapshot for that tick, and the player's own entity is never reconciled
81
+ against a carried-over copy. `stats().partialSnapshots` counts these. Host
82
+ and players must run the same release: an older player would treat each
83
+ chunk as a whole snapshot.
84
+ - **Netcode: correction smoothing and extrapolation.** `joinNetcode`'s
85
+ `correctionSmoothingMs` (default 100, `0` snaps) blends a reconciliation
86
+ correction in instead of jumping; inputs keep applying during the blend.
87
+ `extrapolateMs` (default 0) carries remote entities on along their last
88
+ motion when the newest snapshot is late, capped at that long.
89
+
90
+ - **Entity writes: `setEntities(collection, changes)` / `setEntity(collection,
91
+ id, value)`** on the host handle, for collections that declare a per-entity
92
+ parser in the definition's new `entities` field. Only the written entities
93
+ are validated, and the whole-document `state` validator does not run. At
94
+ 8,000 entities a commit drops from about 16 ms to about 2 ms. `undefined`
95
+ removes an entity. `scripts/bench-world.mjs --write entities` measures it.
96
+ - **Per-player views cost what changed, not the world.** When an entity write
97
+ touches only collections declared in both `interest` and `entities`, and
98
+ the host projects by declared `visibility` rules alone, each player's delta
99
+ projects just the changed entities. 8,000 entities under an `owner` rule
100
+ with 16 players: 459 → 25 ms a commit. `host.counts().sparseViews` counts
101
+ it.
102
+
103
+ - **Netcode: `@net-mesh/browser/netcode`** — `hostNetcode` (fixed-rate tick
104
+ loop; inputs applied exactly once per player however often the lossy
105
+ carrier repeats them; per-player `visible` filter; `authorize`; capped
106
+ lag compensation `rewind(seen)`, default 200 ms) and `joinNetcode` (clock
107
+ sync, snapshot interpolation a delay behind the host, local prediction
108
+ with reconciliation, redundant inputs). Transport-agnostic: a browser
109
+ node, the local mesh, or a Node dedicated host via `meshStoreTransport`.
110
+ Also `ClockEstimator`, `SnapshotBuffer`, `lerpNumbers`.
111
+ - The store's transport type accepts `lossy?: boolean` on `openStream`.
112
+ - **Lossy streams: `openStream({ reliability: 'fireAndForget', lossy: true })`**
113
+ — for high-rate state where only the newest value matters. Every stream
114
+ used to share one ordered, fully retransmitted DataChannel, so one lost
115
+ packet delayed everything behind it. A lossy stream's packets ride a
116
+ second, unordered, zero-retransmit channel (`net-u`): prompt or not at
117
+ all, never delaying anything, and dropped rather than queued behind a
118
+ backed-up buffer. The receiver's fire-and-forget semantics are unchanged
119
+ (gaps skipped, the newest value wins). `connect()` nodes only for now;
120
+ an anchor or peer that opens no lossy channel still gets the packets on
121
+ the reliable one. **Upgrade the anchor with the package**: an older
122
+ anchor treats the second channel as its only one.
123
+
124
+ - **`rememberedIdentity(key?)`** — the same player on every visit for
125
+ `connect()`: the node's two secrets, created once and kept in
126
+ `localStorage` (`connect({ …, ...rememberedIdentity() })`). Without it,
127
+ `connect()` is a new node on every page load.
128
+ - **Fixed: a lobby could not be joined over a real anchor.** A browser node
129
+ accepts a relayed handshake only from a peer whose signed announcement it
130
+ holds, and a joiner announced nothing — so the host refused it and the
131
+ store failed with `no session with 0x…` (the local mesh does not model
132
+ this; a real anchor run found it). `joinLobby` now announces
133
+ `net-lobby:<game>:seek:<host>` while playing (new `tags` option keeps the
134
+ page's own), and retries reaching the host until `timeoutMs`, failing as
135
+ `LobbyError('not-found', "could not reach the lobby's host …")`.
136
+ - **`requestCredential({ anchorUrl, game })`** — an anonymous visitor
137
+ credential from an anchor started with `net-mesh anchor serve --game`,
138
+ returning `{ credentialB64, bootstrapUrl, game }` for `openSession()`.
139
+ Failures are a typed `CredentialRequestError` (`unknown-game`,
140
+ `rate-limited`, `malformed-request`, `unreachable`, `unexpected`).
141
+
142
+ - **Fixed: a change inside a map resent the whole map.** The owner's
143
+ diff goes one level deeper for maps, so moving 25 of 500 ships sends
144
+ those 25, not all 500 (62 KB → 3 KB per player per tick, measured).
145
+ - **Faster declared visibility** (linear instead of quadratic) and no
146
+ re-validation of an identity projection. `npm run bench:world`
147
+ measures host time and bytes per player across world sizes.
148
+ - **Fixed: a validator that cancels during assembly** published the
149
+ cancelled document before clearing it; the installation is now
150
+ abandoned before anything is published.
151
+ - **Interest management.** `defineStore({ interest: { <entity map>:
152
+ (entity, id) => key | null } })`; `joinStore({ interest })`,
153
+ `setInterest(keys)` (also on `hostPlayer` and `joinLobby`). Only
154
+ entities whose key is in the set are delivered, as per-entity ops;
155
+ far changes send nothing; interest changes are one additive delta
156
+ (new `int` wire message; optional `int` on `join` / `resume`). Grid
157
+ helpers `cellKey`, `cellsAround`, `stickyCells`, `sameCells`.
158
+ - **Fixed: a replica sent nothing on a commit then saw a gap.** A delta's
159
+ `base` was the owner's previous revision, so a replica whose view had
160
+ not changed (and was sent nothing) detected a gap on its next delta
161
+ and re-fetched its whole view. `base` is now the revision that
162
+ replica is at.
163
+ - **Declared visibility.** `defineStore({ visibility })`: path → rule
164
+ (`'everyone'`, `'nobody'`, `'owner'`, or audiences; `x.length` reveals a
165
+ hidden array's count), presets `'open'` and `'card-game'` with
166
+ overrides. Enforced by the host after any `project` / `projectFor`
167
+ (which become optional). Hidden entries are removed, hidden fields
168
+ become `HIDDEN` (`hiddenOr`, `isHidden`); a validator that refuses the
169
+ marker is refused at `hostStore`. `assertHidden` and `projectVisible`
170
+ for tests; `hostStore({ dev })` warns about an undeclared
171
+ everything-to-everyone store and about projections the validator
172
+ refuses.
173
+ - **`onEvent(event, context)`** — one host hook for `join`, `leave`
174
+ (with a reason: `left` / `expired` / `refused` / `dropped`) and `area`
175
+ (from `areaOf(state, peer)`, on change only). A transaction like an
176
+ action handler, run after the causing frame, including the host's own
177
+ player; throws are discarded and counted, and runaway chains stop at
178
+ `MAX_EVENT_ROUNDS`.
179
+ - **Inventory bones** — `Inventory` (item id → count), `addItems`,
180
+ `removeItems`, `countItems`, `hasItems`, `inventoryOf`,
181
+ `parseInventory`, `onlyOwn`, with optional `maxKinds` / `maxCount`
182
+ rules and a typed `InventoryError`. Trading is not included.
183
+ - **Lobbies.** `createLobby()` hosts a store with the host's own
184
+ player (`lobby.self`), a room code, a shareable link, presence
185
+ (`players()`, `subscribePlayers`), capacity (the host counted) and
186
+ immediate kicks; `listLobbies()` lists a game's public lobbies;
187
+ `joinLobby()` joins by code, link or listing. Discovery is capability
188
+ tags in the host's signed announcement — no new protocol. Records are
189
+ bounded (256-byte `info`, 512-byte tag) and validated when read; a
190
+ code claimed by two nodes is refused as `ambiguous`; unlisted lobbies
191
+ publish only a hash of their code.
192
+ - **`hostPlayer(host, { audience })`** — the hosting node's own player,
193
+ with the handle shape `joinStore` returns. A node cannot join its own
194
+ store, so every game whose host also plays wrote a wrapper around the
195
+ handlers; this replaces it and holds the host's player to parity with
196
+ a replica: the host's `authorize` with its own node id as `peer`,
197
+ input and output validation and the result budget inside one
198
+ transaction, the wire's value rules, and `getState()` as the
199
+ projection for its audience rather than the raw document.
200
+ `HostedStoreHandle` now carries its `definition` and its action and
201
+ input types.
202
+ - **`projectFor(state, { peer, audience })`** — a per-player
203
+ projection, given to `hostStore` instead of `project`, for views that
204
+ depend on who is looking (your own hand). Computed once per distinct
205
+ player and audience; a player whose view did not change is sent
206
+ nothing. Exactly one of `project` and `projectFor` is accepted, in the
207
+ types (`HostProjection`) and at construction (`invalid-data`).
208
+ - **`@net-mesh/browser/local`** — `createLocalMesh()`, several nodes in
209
+ one page with no anchor and no network, each a `StoreTransport`. The
210
+ store on top is the real one; delivery is a function call and the
211
+ peer is assigned rather than proved. Local nodes also `announce` and
212
+ `query` in the real descriptor shape, with the leaf's lease (another
213
+ node's announcement only, expiring after 300 s by default), so
214
+ discovery code runs offline. For building game logic first;
215
+ the demo's `demo/local-mesh.js` is replaced by it.
216
+
217
+ ## The initial surface (developed for 0.36, first published in 0.37.0)
218
+
219
+ ### Added
220
+
221
+ - **The game store.** `defineStore()`, `hostStore()` and
222
+ `joinStore()`: one authoritative document served to replicas over a
223
+ mesh stream. A node may host several stores of one definition, so
224
+ each names itself — `hostStore({store})` declares an address and
225
+ `joinStore({store})` asks for one; both default to the definition
226
+ id. The address rides on the `join` frame, because every host store
227
+ on a node sees every frame and a join names no handle. Two stores
228
+ answering to one name on one transport is refused at construction.
229
+ Each store serves with audience projections, correlated actions,
230
+ coalesced inputs, chunked snapshots and a replica that recovers a
231
+ lost manifest or a stalled assembly by asking again. `StoreError`
232
+ carries the code a caller branches on. See `demo/README.md` for the
233
+ worked example and the vocabulary.
234
+ - **`@net-mesh/browser/three`.** `bindEntities()` — a subpath export
235
+ that reconciles a store's entity map into any scene graph with
236
+ `add`/`remove`, skipping entities whose reference did not change and
237
+ removing what left the world. It imports nothing from `three`: the
238
+ types are structural, so the package gains no renderer dependency
239
+ and the binding drives a `THREE.Scene` or a test double equally.
240
+
241
+ - **The package.** `connect()`, `BrowserNode` (`call`, `subscribe`,
242
+ `publish`, `openStream`, `announce`, `query`, `signal`, `enroll`,
243
+ `isEnrolled`, `anchorIdHex`, `nodeIdHex`, `counters`, `close`),
244
+ `LeafStream`, and a
245
+ typed event surface (`on`, `onEvent`, `events()`) over the
246
+ `net-mesh-leaf` wasm node. `query` resolves parsed descriptors
247
+ (`{ nodeId, entityId, capabilities, rtcAddr, noisePubkey, version }`)
248
+ and `counters()` parsed counters, both with `u64`s as exact strings.
249
+
250
+ - **`openSession()` and `MeshSession`** — §8's leader/follower surface:
251
+ one node per origin, elected by Web Lock, with every tab driving it
252
+ through the same API. `role()`, `generation()`, `fingerprint()`,
253
+ `scope()`, `interruptionMs()`, `onLifecycle()` and the five lifecycle
254
+ events (`leader_changed`, `subscription_restored`, `leader_lost`,
255
+ `generation_fenced`, `not_leader`) on top of the direct surface's
256
+ operations. `counters()`, `isEnrolled()` and `openStream()` are
257
+ promises here because on a follower the work happens in another tab.
258
+ Prefer it over `connect()`: two tabs calling `connect()` on one origin
259
+ are two nodes contending for one identity.
260
+
261
+ - **Typed errors mirroring `net_leaf::error`.** Every rejection is a
262
+ `LeafError` subclass whose `.message` is verbatim the Rust `Display`
263
+ text and whose `.kind` is one flat, stable string per Rust variant
264
+ (`wire`, `session`, `control-plane`, `identity`, `not-leader`,
265
+ `ice-timeout`, `udp-blocked`, `channel-closed`, `rtc-unsupported`,
266
+ `rpc-refused`, `rpc-timeout`, `session-lost`, `leader-lost`,
267
+ `rpc-indeterminate`, `rpc-malformed`). An unrecognised message
268
+ becomes `UnknownLeafError`
269
+ rather than being folded into a near neighbour.
270
+
271
+ - **The `udp-blocked` classification, with its evidence.** An ICE
272
+ failure surfaces as `ice-timeout` unless two observations hold
273
+ together: the anchor answered its HTTPS bootstrap, and a STUN binding
274
+ to the `rtc_addr` that anchor published went unanswered.
275
+ `classifyRtcFailure()` is the pure rule; `probeStunBinding()` and
276
+ `probeBootstrapReachable()` produce the observations. Supply
277
+ `connect({ anchorRtcAddr })` when the page knows the anchor's address
278
+ before its first `connected` event; `connect({ failureTyping: {
279
+ probeOnIceTimeout: false } })` switches the probe off, which means
280
+ `ice-timeout` always.
281
+
282
+ - **Custodial identity.** `connect({ entitySecretHex, noiseSecretHex })`
283
+ (32 bytes of hex each) makes the leaf build its identity from those
284
+ secrets instead of generating one, so two pages given the same pair
285
+ are the same node id. `noiseSecretHex` is read only alongside
286
+ `entitySecretHex`. An unusable option — a secret that is not 64 hex
287
+ digits, or
288
+ `noiseSecretHex` without its entity half — rejects with
289
+ `IdentityError` before the leaf is called, never falling through to a
290
+ generated identity.
291
+
292
+ - **`npm run size`** — raw and gzipped bytes for the wasm, the
293
+ wasm-bindgen glue, the ESM directory and the single-file bundle, with
294
+ `SIZE <artifact> raw=<n> gz=<n>` lines for CI, a comparison against
295
+ the S0a baseline, and `--assert` / `--require-leaf` exit codes.
296
+
297
+ ### Fixed
298
+
299
+ - **Several stores on one node no longer refuse each other's players.**
300
+ Every hosted store sees every frame, and a store that did not know a
301
+ handle answered `closed` for it. With two stores of one definition on a
302
+ node (the `store` option, for example two regions of a world), a player's
303
+ action could be refused by the wrong store. Now only the store that issued
304
+ the handle answers, and a handle no store issued gets a single `closed`.
305
+
306
+ - **A refused `act` or `setInterest` no longer raises an unhandled
307
+ rejection.** When the refusal arrived while the request's `send` was still
308
+ pending (a native host answers that fast), the request's promise rejected
309
+ before anything was listening. The caller still got the error, but the page
310
+ also saw `unhandledrejection`.
311
+ - **`connectPeer` is idempotent on a healthy direct pair.** It resolves
312
+ `{ type: 'direct' }` with the live dialog and sends nothing. Before this,
313
+ it made a fresh offer whose connection replaced — and closed — the working
314
+ link under the peer. The pair still read as direct while every send to it
315
+ was refused. This hit any library that calls `connectPeer` before opening a
316
+ stream (netcode, lobbies). The same holds for `openSession().connectPeer`:
317
+ the follower asks the leader through a new proxied reading. A leader from an
318
+ older release doesn't answer it, and the follower then offers as before.
319
+
320
+ ### Changed
321
+
322
+ - **A stream terminal's final body is no longer dropped.** A completion
323
+ frame carrying a non-empty final body now surfaces as
324
+ `{ done: true, value }` from `next()` — the done item's `value` is
325
+ `Uint8Array | undefined` — and `for await` yields those bytes before
326
+ the loop ends. Previously the value was silently discarded at the
327
+ seam, so a provider's final bytes never reached the page.
328
+
329
+ - **A peer session is not usable the instant a handshake completes.**
330
+ A node that receives an inbound peer establishment now attributes it
331
+ only after the initiator's signed establishment proof verifies
332
+ against the identity in its announcement, so there is a brief window
333
+ in which the peer is connecting but not yet connected. A page sees
334
+ this only as `connectPeer`/`acceptPeer` resolving a few milliseconds
335
+ later; what changed is that a peer claiming an identity it does not
336
+ own never reaches your application at all. Nothing in the public API
337
+ moved, and no page supplies keys — the implementation resolves them.
338
+
339
+ - **`NodeDescriptor.nodeId` is decimal; `connectPeer()` takes
340
+ 16-digit hex.** These were always different spellings and the
341
+ documentation wrongly equated them, which is a data-loss trap: a
342
+ decimal string can be a valid-looking hex length and address a
343
+ different node. The types and docs now say so and a conversion is
344
+ provided. Deliberately NOT normalized silently — a peer id you did
345
+ not mean is worse than an error you can see.
346
+
347
+ - **`candidateError` survives to the status surface.** It was emitted
348
+ by the leaf and discarded by the TypeScript parser, leaving a
349
+ console warning as the only trace of an ICE candidate the browser
350
+ refused. It is machine-readable again.
351
+
352
+ - **Trickle candidates are retained across the phases that used to
353
+ drop them** — a candidate arriving before the page accepts an offer,
354
+ or before an answer makes a remote description usable, is held under
355
+ the dialog that authorized it and applied when it can be, bounded at
356
+ 32 per queue. Previously such candidates were spent on a call that
357
+ could only fail, which cost connectivity on paths where the winning
358
+ pair arrived early.
359
+
360
+ - **`retryReport()` reports what the policy will actually do**, and
361
+ gains `discardedUnarmed`. The ICE-failure watcher previously acted
362
+ while the report said retry was unarmed, and a node that had been
363
+ both offerer and answerer for one peer stayed eligible to initiate
364
+ repair from both ends.
365
+
366
+ - **Iterators returned by a direct `BrowserNode` now complete when
367
+ `close()` is called.** A consumer sitting in
368
+ `for await (const bytes of stream)` leaves the loop and one awaiting
369
+ `iterator.next()` resolves `{ value: undefined, done: true }`;
370
+ `onMessage` listeners are dropped. Previously `node.close()` closed
371
+ the wasm node and left every iterator it had handed out parked
372
+ forever, because the wasm side stops calling `on_message` and
373
+ nothing else could end the queue. Opening a stream on a node that
374
+ is already closed is now visibly a typed `SessionError`
375
+ (`kind: 'session'`,
376
+ `session: the node is closed: it no longer holds this origin's identity`)
377
+ rather than a handle that silently sends nothing.
378
+
379
+ This is a **behaviour change** for code that relied on an iterator
380
+ outliving its node, or on a `for await` loop parking until some
381
+ other mechanism tore it down: that loop now finishes. Nothing new
382
+ is thrown at the consumer — the terminal is the normal end of
383
+ iteration — so a loop that already handles "the stream ended" needs
384
+ no change, and one whose only exit was an `AbortController` can drop
385
+ it.
386
+
387
+ **`MeshSession` already behaved this way and is unchanged; it is the
388
+ direct path that moved.** The leader-proxied surface has always
389
+ ended its streams on a generation change or a leadership loss,
390
+ because Rust fences a stale handle by the generation it was opened
391
+ under and a consumer parked on a handle that will never emit again
392
+ has to be settled by something. `BrowserNode` was the outlier, and
393
+ the two surfaces now dispose of a stream identically.
394
+
395
+ - **`BrowserNode.close()` finishes what it latched, and reports what
396
+ failed.** It closes every stream it handed out, then the event
397
+ surface, then the leaf — and each of those is now attempted
398
+ independently. Previously the first stream whose `close` threw
399
+ aborted the loop, and because `close` latches and empties its set
400
+ before starting, nothing could retry: the remaining streams stayed
401
+ open with their iterators parked forever, the event surface kept
402
+ dispatching, the wasm node was never closed, and a second
403
+ `close()` returned immediately.
404
+
405
+ If anything did throw, `close()` now throws an **`AggregateError`**
406
+ whose `.errors` are the typed `LeafError`s in teardown order —
407
+ always an aggregate, however many failed, so a caller does not have
408
+ to branch on a shape to discover that one part of its teardown did
409
+ not happen. The ordinary path still throws nothing. This is
410
+ reachable because `LeafWasmStreamLike` is a public interface a host
411
+ may implement; the leaf's own `close` does not throw.
412
+
413
+ - **A stream delivers its own peer's payloads, not its id's.**
414
+ `stream_data` now carries `peerNode` (and `incarnation`), and a
415
+ stream matches an inbound frame on `(peerNode, streamId)` rather
416
+ than on the id alone.
417
+
418
+ A stream id is an application label scoped to a session, so
419
+ `openStream({ peer, streamId })` against two peers under one id is
420
+ an ordinary composition — and the wasm callback is handed the
421
+ node-wide event vector. Keyed on the id alone, both wrappers
422
+ admitted whichever peer's frame arrived first, so two streams under
423
+ one label received each other's bytes and a page that echoed what
424
+ it received amplified one peer's payload onto the other's stream.
425
+ The pair-specific-label workaround pages were told to use is not
426
+ needed for this and was never the fix.
427
+
428
+ A host-supplied `LeafWasmStreamLike` that spells no peer
429
+ (`peer_node_hex` is optional) keeps the id-only behaviour: a filter
430
+ that cannot be evaluated must not become one that drops
431
+ everything. If you implement that interface, add `peer_node_hex()`
432
+ — 16 lowercase hex digits, the spelling `nodeIdHex()` hands out —
433
+ to get the peer-keyed delivery.
434
+
435
+ ### Notes for consumers
436
+
437
+ - **64-bit ids are exact decimal strings**, never JS numbers.
438
+ `channel_hash`, `origin_hash`, `stream_id`, `node_id`, `peer_node`,
439
+ `dialog`, `not_after`, `call_id`, `seq`, `incarnation` and
440
+ `generation` all arrive as
441
+ strings, because `JSON.parse` rounds integer literals above 2^53 and
442
+ a rounded channel hash would match the wrong channel.
443
+
444
+ - **Event tags are the leaf's vocabulary, verbatim** —
445
+ `channel_message`, not `channelMessage`. Field names are camelCased.
446
+ An unknown tag arrives as `{ type: 'unknown', tag, raw }`.
447
+
448
+ - **`session-lost` and `leader-lost` are never retried for you.**
449
+ They are surfaced typed, and the caller decides.
450
+
451
+ - **Stream payloads are decoded here, not encoded twice in Rust.** The
452
+ wasm boundary delivers the node's `stream_data` event JSON to a
453
+ stream's callback —
454
+ `{"type":"stream_data","peer_node":"200","incarnation":"1",`
455
+ `"stream_id":"9","seq":"1","payload":"AQI="}` — and `LeafStream`
456
+ parses and
457
+ base64-decodes it, so `onMessage` and `for await` yield
458
+ `Uint8Array` on the direct and the leader-proxied surface alike.
459
+ The declaration in `src/wasm.ts` used to say `Uint8Array` and the
460
+ argument was forwarded verbatim, so a page received the JSON string.
461
+ If you wired `LeafWasmStream.on_message` yourself, parse it — and
462
+ filter on `peer_node` as well as `stream_id`.
463
+
464
+ - **`OpenStreamOptions.channelHash` is a `number`**, not a string, and
465
+ must be a whole number in `0..=65535`. Rust reads it as a number;
466
+ the string the old type asked for was read as absent, and the stream
467
+ rode channel hash 0. A string, a fraction or an out-of-range value
468
+ is now a typed error instead of a silently different channel.
469
+ `streamId` stays a decimal or `0x`-hex **string** — and a number
470
+ there is refused rather than ignored.
471
+
472
+ - **`ConnectOptions.iceServers` is honoured.** The leaf now parses
473
+ real `RTCIceServer` objects (`urls` as a string or an array, plus
474
+ `username`/`credential` for TURN) and configures the
475
+ `RTCPeerConnection` with them. It previously read each entry with
476
+ `as_string`, so every object a page passed was dropped and its
477
+ STUN/TURN configuration never reached the offer. A bare URL string
478
+ is refused rather than accepted as a second spelling.
479
+
480
+ - **`LeafNode.effective_ice_servers(opts)` and
481
+ `LeafNode.effective_stream_options(opts)`** are static, pure readers
482
+ on the wasm surface that report what `connect()` / `open_stream()`
483
+ would actually use, so the ABI can be asserted against the built
484
+ package without an anchor. `tests/abi_real_package.mjs` does exactly
485
+ that against `dist/` and the `pkg/` beside it.
486
+
487
+ - **`ConnectOptions.iceServers` now has a working default, and one
488
+ refusal.** Omitted, the leaf gathers against the STUN endpoint the
489
+ anchor announces separately — `stun_addr` on `GET /rtc/anchor`, a
490
+ second UDP endpoint distinct from `rtc_addr` — so the advertised
491
+ configuration works without a page choosing a STUN service. An
492
+ anchor that announces none leaves the connection with no ICE
493
+ servers, which is what it had before. An explicit `iceServers: []`
494
+ is still a caller choosing none, and is honoured: the default turns
495
+ on the key's absence, not on emptiness.
496
+
497
+ Supplied entries are honoured verbatim except one, which is now
498
+ `IceServerConflictError` (`kind: 'ice-server-conflict'`) raised
499
+ **before any ICE work**: an entry whose STUN endpoint is this
500
+ connection's own peer RTC endpoint. An anchor's `rtc_addr` is that
501
+ connection's ICE peer, not a STUN server for it, so the connection
502
+ gathers no server-reflexive candidate and times out. It is refused
503
+ rather than silently stripped — stripping turns an explicit
504
+ NAT-traversal configuration into a host-candidate-only attempt
505
+ while appearing to have accepted it. The error carries `entry` and
506
+ `peerRtcAddr`. Detection is endpoint equality only (after
507
+ default-port normalisation); a DNS alias that resolves to the peer
508
+ is not detected, and the announced endpoint is what makes detection
509
+ unnecessary for the configuration Net supplies.
510
+
511
+ - **`stunUrl` is now `diagnosticStunUrl`.** Same behaviour, honest
512
+ name: it builds the throwaway UDP probe's target out of `rtc_addr`
513
+ and is for `probeStunBinding` only. The old name implied that
514
+ turning `rtc_addr` into a `stun:` URL made it suitable for that
515
+ anchor's own connection — the configuration the leaf now refuses,
516
+ and the one both of this repo's harnesses had adopted. There is no
517
+ alias: update the call site.