@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
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.
|