@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,174 @@
1
+ /**
2
+ * Streams, as a page wants them: an async iterable of payloads with a
3
+ * `send` that rejects with a typed error.
4
+ */
5
+ import { SessionError } from './errors.js';
6
+ import type { StreamReliability } from './wasm.js';
7
+ /**
8
+ * Whether a stream must be able to name its peer.
9
+ *
10
+ * `'required'` for every stream this package builds — both the direct
11
+ * and the proxied wasm handle report one. `'optional'` exists for a
12
+ * host-supplied wrapper in a test or an embedding that has no peer to
13
+ * report, and it keeps the pre-repair id-only behaviour, which is why
14
+ * nothing in `src/` passes it.
15
+ */
16
+ export type StreamIdentity = 'required' | 'optional';
17
+ /**
18
+ * A stream could not name its peer, so its frames are unattributable.
19
+ *
20
+ * Thrown at construction rather than tolerated: a stream that cannot
21
+ * filter by peer admits any peer's frame carrying a matching id.
22
+ */
23
+ export declare class StreamIdentityError extends SessionError {
24
+ constructor(message: string);
25
+ }
26
+ /** Options for {@link BrowserNode.openStream}. */
27
+ export interface OpenStreamOptions {
28
+ /**
29
+ * `reliable` retransmits and reorders by `seq`; `fireAndForget`
30
+ * neither, which is the point of it — a dropped frame stays dropped
31
+ * and the consumer sees the gap.
32
+ */
33
+ reliability: StreamReliability;
34
+ /** A human label for logs. */
35
+ label?: string;
36
+ /**
37
+ * Used verbatim when present, so a stream can match a publish
38
+ * contract a native handler dispatches on.
39
+ */
40
+ streamId?: string;
41
+ /**
42
+ * Likewise verbatim: the `u16` channel hash the stream rides.
43
+ * A number — see {@link LeafWasmStreamOptions.channelHash} for
44
+ * why it is not a string.
45
+ */
46
+ channelHash?: number;
47
+ /**
48
+ * The node this stream addresses — **16 hex digits**, the spelling
49
+ * {@link BrowserNode.nodeId} hands out and
50
+ * {@link BrowserNode.connectPeer} takes. Absent, it is the anchor,
51
+ * which is what every caller got before this option existed.
52
+ *
53
+ * This is the page-facing half of §9: `connectPeer` installs a
54
+ * direct leaf ↔ leaf session and this is what puts application
55
+ * bytes on it. The addressing is the leaf's, not a second path —
56
+ * a stream to a peer whose session is still ROUTED rides the
57
+ * anchor, and needs no second call to start riding the
58
+ * DataChannel.
59
+ *
60
+ * **The handle does not survive the direct upgrade, though.** §9
61
+ * step 4 installs a *replacement* session, and a stream handle is
62
+ * fenced to the incarnation it was opened on — so `send` on a
63
+ * stream opened while the pair was routed rejects with
64
+ * {@link SessionError} ("stale stream handle … reopen the
65
+ * stream") once the direct session lands. Reopening is one call
66
+ * with the same `peer` and the same `streamId`; a page driving a
67
+ * peer across the upgrade should reopen on that rejection rather
68
+ * than assume continuity.
69
+ *
70
+ * A peer with no session at all rejects typed from `send`.
71
+ *
72
+ * Accepted by {@link MeshSession.openStream} as well. A follower's
73
+ * stream is opened by the leader tab's node, and the request
74
+ * carries this peer, so a follower addresses a direct
75
+ * leaf-to-leaf session exactly as the leader tab does. It used to
76
+ * be refused by name there, because the proxy request had no peer
77
+ * field to carry.
78
+ */
79
+ peer?: string;
80
+ /**
81
+ * **Ride the lossy carrier** — for high-rate state where only the newest
82
+ * value matters (positions, inputs). With `reliability: 'fireAndForget'`
83
+ * only; `true` with `'reliable'` throws.
84
+ *
85
+ * Every stream normally shares one ordered, fully retransmitted
86
+ * DataChannel, so one lost packet delays everything behind it. A lossy
87
+ * stream's packets ride a second, unordered channel with no
88
+ * retransmits: each arrives promptly or not at all, and none delays
89
+ * anything else. A packet that would queue behind a backed-up buffer is
90
+ * dropped instead (it would be stale on arrival). The receiver sees a
91
+ * fire-and-forget stream as always: gaps are skipped and anything older
92
+ * than what it already delivered is dropped — the newest value wins.
93
+ *
94
+ * Direct `connect()` nodes only for now: {@link MeshSession.openStream}
95
+ * refuses it. A peer or anchor that opens no lossy channel still gets
96
+ * the packets, on the reliable one.
97
+ */
98
+ lossy?: boolean;
99
+ }
100
+ /**
101
+ * One open stream.
102
+ *
103
+ * Inbound payloads are delivered to `for await` consumers and to
104
+ * {@link LeafStream.onMessage} listeners. Payloads that arrive before
105
+ * anyone consumes them are buffered in arrival order, so a page that
106
+ * opens a stream and awaits it a tick later loses nothing.
107
+ */
108
+ export declare class LeafStream implements AsyncIterable<Uint8Array> {
109
+ private readonly inner;
110
+ private readonly onClosed?;
111
+ private readonly queues;
112
+ private readonly listeners;
113
+ private readonly pending;
114
+ private closed;
115
+ /**
116
+ * This stream's wire id as a number, or `null` when the inner
117
+ * object's `stream_id_hex()` was not hex.
118
+ *
119
+ * The event JSON spells the id in **decimal** while the handle
120
+ * spells it in **hex**; comparing the two textually would drop
121
+ * every payload, so the comparison is numeric. Rust filters the
122
+ * event stream too — this is the exact check behind its substring
123
+ * one, and the reason a mismatch is a dropped payload here rather
124
+ * than someone else's bytes.
125
+ */
126
+ private readonly wireId;
127
+ /**
128
+ * The peer this stream addresses, as a number, or `null` when the
129
+ * inner object does not spell one.
130
+ *
131
+ * **The other half of a stream's identity, and the reason the id
132
+ * alone was not one.** A stream id is an application label scoped
133
+ * to a session, so two streams to two peers under the same id is
134
+ * the ordinary case — `openStream({ peer, streamId })` invites it
135
+ * — and the callback is handed the NODE-WIDE event vector. Keyed
136
+ * on the id alone, two wrappers both admitted whichever peer's
137
+ * frame arrived, so a page echoing what it received amplified one
138
+ * peer's payload onto the other's stream (R4-10). The key is
139
+ * `(peer, id)`.
140
+ *
141
+ * Read in the same hex spelling as the id and compared against the
142
+ * event's decimal `peer_node` the same way, so there is one
143
+ * conversion idiom in this file rather than two — a mismatch in
144
+ * either would fail the id comparison too, loudly.
145
+ */
146
+ private readonly peerId;
147
+ /** The authenticated peer this stream is with, 16 lowercase hex. */
148
+ get peerNode(): string;
149
+ /**
150
+ * Whether this stream retransmits. Read from the leaf rather than
151
+ * from the options it was asked for, so it is the stream's own
152
+ * answer.
153
+ */
154
+ get reliability(): StreamReliability;
155
+ /** The stream's wire id, hex. */
156
+ get streamId(): string;
157
+ /** Send one payload. Fragmentation at `MAX_PAYLOAD_SIZE` is the leaf's. */
158
+ send(payload: Uint8Array): Promise<void>;
159
+ /** Listen for payloads. Returns a cancel handle. */
160
+ onMessage(listener: (payload: Uint8Array) => void): () => void;
161
+ [Symbol.asyncIterator](): AsyncIterableIterator<Uint8Array>;
162
+ /** Close the stream and end every consumer. */
163
+ close(): void;
164
+ /**
165
+ * One callback from the wasm boundary.
166
+ *
167
+ * Rust emits the node's `stream_data` event JSON; this is where
168
+ * it becomes the `Uint8Array` the public API promises. Bytes are
169
+ * taken as an already-decoded payload, which is what a
170
+ * host-supplied {@link LeafWasmStreamLike} may deliver.
171
+ */
172
+ private receive;
173
+ private decode;
174
+ }
package/dist/stream.js ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Streams, as a page wants them: an async iterable of payloads with a
3
+ * `send` that rejects with a typed error.
4
+ */
5
+ import { AsyncQueue } from './async-queue.js';
6
+ import { fromWasmError, SessionError } from './errors.js';
7
+ import { parseEvent } from './events.js';
8
+ /**
9
+ * A stream could not name its peer, so its frames are unattributable.
10
+ *
11
+ * Thrown at construction rather than tolerated: a stream that cannot
12
+ * filter by peer admits any peer's frame carrying a matching id.
13
+ */
14
+ export class StreamIdentityError extends SessionError {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'StreamIdentityError';
18
+ }
19
+ }
20
+ /**
21
+ * One open stream.
22
+ *
23
+ * Inbound payloads are delivered to `for await` consumers and to
24
+ * {@link LeafStream.onMessage} listeners. Payloads that arrive before
25
+ * anyone consumes them are buffered in arrival order, so a page that
26
+ * opens a stream and awaits it a tick later loses nothing.
27
+ */
28
+ export class LeafStream {
29
+ inner;
30
+ onClosed;
31
+ queues = new Set();
32
+ listeners = new Set();
33
+ pending = [];
34
+ closed = false;
35
+ /**
36
+ * This stream's wire id as a number, or `null` when the inner
37
+ * object's `stream_id_hex()` was not hex.
38
+ *
39
+ * The event JSON spells the id in **decimal** while the handle
40
+ * spells it in **hex**; comparing the two textually would drop
41
+ * every payload, so the comparison is numeric. Rust filters the
42
+ * event stream too — this is the exact check behind its substring
43
+ * one, and the reason a mismatch is a dropped payload here rather
44
+ * than someone else's bytes.
45
+ */
46
+ wireId;
47
+ /**
48
+ * The peer this stream addresses, as a number, or `null` when the
49
+ * inner object does not spell one.
50
+ *
51
+ * **The other half of a stream's identity, and the reason the id
52
+ * alone was not one.** A stream id is an application label scoped
53
+ * to a session, so two streams to two peers under the same id is
54
+ * the ordinary case — `openStream({ peer, streamId })` invites it
55
+ * — and the callback is handed the NODE-WIDE event vector. Keyed
56
+ * on the id alone, two wrappers both admitted whichever peer's
57
+ * frame arrived, so a page echoing what it received amplified one
58
+ * peer's payload onto the other's stream (R4-10). The key is
59
+ * `(peer, id)`.
60
+ *
61
+ * Read in the same hex spelling as the id and compared against the
62
+ * event's decimal `peer_node` the same way, so there is one
63
+ * conversion idiom in this file rather than two — a mismatch in
64
+ * either would fail the id comparison too, loudly.
65
+ */
66
+ peerId;
67
+ /**
68
+ * @internal — built by `BrowserNode.openStream` (a leader-local
69
+ * stream, whose `send` is synchronous) or by
70
+ * `MeshSession.openStream` (a proxied one, whose `send` is a
71
+ * promise because another tab puts the packet on the wire). A page
72
+ * writes `await stream.send(bytes)` either way.
73
+ *
74
+ * `onClosed` tells the owner it may forget this stream — the same
75
+ * shape {@link AsyncQueue} uses for a departing consumer. A node
76
+ * has to retain the streams it handed out to end them on its own
77
+ * close, and `BrowserNode` passes this so a page that opens and
78
+ * closes a stream per frame does not grow that set for the node's
79
+ * lifetime.
80
+ */
81
+ constructor(inner, onClosed, identity = 'required') {
82
+ this.inner = inner;
83
+ this.onClosed = onClosed;
84
+ this.wireId = u64FromHex(() => inner.stream_id_hex());
85
+ this.peerId = u64FromHex(() => inner.peer_node_hex());
86
+ if (identity === 'required' && this.peerId === null) {
87
+ // **Fail closed.** Every stream this package builds comes from
88
+ // its own wasm, where both the direct and the proxied handle
89
+ // report a peer, so an unreadable one is a broken build or a
90
+ // substituted object — not a stream with no peer. Admitting it
91
+ // would restore the id-only filter below, which is the R4-10
92
+ // cross-peer admixture, and it would do so silently.
93
+ throw new StreamIdentityError('the stream did not report a readable peer, so its frames cannot be attributed');
94
+ }
95
+ inner.on_message((event) => this.receive(event));
96
+ }
97
+ /** The authenticated peer this stream is with, 16 lowercase hex. */
98
+ get peerNode() {
99
+ return this.inner.peer_node_hex();
100
+ }
101
+ /**
102
+ * Whether this stream retransmits. Read from the leaf rather than
103
+ * from the options it was asked for, so it is the stream's own
104
+ * answer.
105
+ */
106
+ get reliability() {
107
+ return this.inner.is_reliable() ? 'reliable' : 'fireAndForget';
108
+ }
109
+ /** The stream's wire id, hex. */
110
+ get streamId() {
111
+ return this.inner.stream_id_hex();
112
+ }
113
+ /** Send one payload. Fragmentation at `MAX_PAYLOAD_SIZE` is the leaf's. */
114
+ async send(payload) {
115
+ if (this.closed)
116
+ throw new SessionError('the stream is closed');
117
+ try {
118
+ await this.inner.send(payload);
119
+ }
120
+ catch (error) {
121
+ throw fromWasmError(error);
122
+ }
123
+ }
124
+ /** Listen for payloads. Returns a cancel handle. */
125
+ onMessage(listener) {
126
+ this.listeners.add(listener);
127
+ const buffered = this.pending.splice(0, this.pending.length);
128
+ for (const payload of buffered)
129
+ listener(payload);
130
+ return () => {
131
+ this.listeners.delete(listener);
132
+ };
133
+ }
134
+ [Symbol.asyncIterator]() {
135
+ const queue = new AsyncQueue(() => this.queues.delete(queue));
136
+ this.queues.add(queue);
137
+ const buffered = this.pending.splice(0, this.pending.length);
138
+ for (const payload of buffered)
139
+ queue.push(payload);
140
+ if (this.closed)
141
+ queue.end();
142
+ return queue;
143
+ }
144
+ /** Close the stream and end every consumer. */
145
+ close() {
146
+ if (this.closed)
147
+ return;
148
+ this.closed = true;
149
+ for (const queue of [...this.queues])
150
+ queue.end();
151
+ this.queues.clear();
152
+ this.listeners.clear();
153
+ // Before the boundary call, so an owner's bookkeeping is correct
154
+ // even if a host-supplied wrapper's `close` throws.
155
+ this.onClosed?.();
156
+ this.inner.close();
157
+ }
158
+ /**
159
+ * One callback from the wasm boundary.
160
+ *
161
+ * Rust emits the node's `stream_data` event JSON; this is where
162
+ * it becomes the `Uint8Array` the public API promises. Bytes are
163
+ * taken as an already-decoded payload, which is what a
164
+ * host-supplied {@link LeafWasmStreamLike} may deliver.
165
+ */
166
+ receive(event) {
167
+ const payload = this.decode(event);
168
+ if (payload === null || this.closed)
169
+ return;
170
+ if (this.queues.size === 0 && this.listeners.size === 0) {
171
+ this.pending.push(payload);
172
+ return;
173
+ }
174
+ for (const listener of this.listeners) {
175
+ try {
176
+ listener(payload);
177
+ }
178
+ catch (error) {
179
+ // Same rule as the event hub: a listener's bug must not unwind
180
+ // through the wasm callback frame that called us.
181
+ console.error('[@net-mesh/browser] stream listener threw', error);
182
+ }
183
+ }
184
+ for (const queue of this.queues)
185
+ queue.push(payload);
186
+ }
187
+ decode(event) {
188
+ if (event instanceof Uint8Array)
189
+ return event;
190
+ if (typeof event !== 'string') {
191
+ console.error('[@net-mesh/browser] stream callback received', typeof event, 'not the leaf event JSON');
192
+ return null;
193
+ }
194
+ const parsed = parseEvent(event);
195
+ // The node's event stream carries more than this stream: Rust
196
+ // pre-filters, but the id it filters on is a substring match, so
197
+ // the exact one lives here.
198
+ if (parsed.type !== 'stream_data' || !/^\d+$/.test(parsed.streamId))
199
+ return null;
200
+ if (this.wireId !== null && BigInt(parsed.streamId) !== this.wireId)
201
+ return null;
202
+ // And the peer, because the id is only half of the identity. A
203
+ // frame whose peer is unattributable is dropped by a stream that
204
+ // knows its own: "some peer's bytes under my id" is precisely
205
+ // what this stream must not deliver. A stream whose HANDLE does
206
+ // not spell a peer keeps the id-only behaviour instead of
207
+ // dropping everything — the same disposition an unreadable id
208
+ // gets, one line above.
209
+ if (this.peerId !== null) {
210
+ if (!/^\d+$/.test(parsed.peerNode))
211
+ return null;
212
+ if (BigInt(parsed.peerNode) !== this.peerId)
213
+ return null;
214
+ }
215
+ return parsed.payload;
216
+ }
217
+ }
218
+ /**
219
+ * A `u64` the inner object spells in hex, as a number — or `null`
220
+ * when it does not spell one.
221
+ *
222
+ * A host-supplied wrapper (`identity: 'optional'`) may return
223
+ * something unreadable, and for that case a value that cannot be read
224
+ * must not become a filter that drops everything. For a stream this
225
+ * package built, the constructor has already refused.
226
+ */
227
+ function u64FromHex(read) {
228
+ let hex;
229
+ try {
230
+ hex = read();
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ if (hex === undefined || !/^[0-9a-fA-F]{1,16}$/.test(hex))
236
+ return null;
237
+ return BigInt(`0x${hex}`);
238
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `@net-mesh/browser/three` — the store, bound to a scene graph.
3
+ *
4
+ * The adapter the Fleet demo grew, extracted because every game that
5
+ * renders a networked store writes the same twenty lines and gets the
6
+ * same two things wrong: it rebuilds objects that did not change, and
7
+ * it leaks the ones that went away.
8
+ *
9
+ * ## No runtime dependency on Three.js
10
+ *
11
+ * Nothing here imports `three`. The scene graph is a structural type —
12
+ * anything with `add` and `remove` — and the objects are whatever the
13
+ * caller's `create` returns. So the browser package's bundle does not
14
+ * grow by a renderer, and the same binding drives a Three.js scene, a
15
+ * canvas, or a test double.
16
+ *
17
+ * `three` is **not declared as a dependency of any kind** — not a
18
+ * peer dependency either, and the manifest says so by having no
19
+ * `peerDependencies` key. An earlier version of this comment called
20
+ * it an optional peer dependency, which would have had npm warn on a
21
+ * missing or mismatched `three`: a promise the manifest does not
22
+ * make. The types it would have supplied are not needed, because the
23
+ * ones here are structural.
24
+ *
25
+ * ## What it is for
26
+ *
27
+ * The store already shares the references of subtrees that did not
28
+ * change (`state.ts`'s reconciliation). That promise is worthless if
29
+ * the render layer rebuilds everything anyway, and it is exactly what
30
+ * a naive `for (const entity of Object.values(state.entities))` loop
31
+ * throws away. This binding turns it into the thing you wanted from
32
+ * it: an entity whose reference is unchanged is not touched, so a
33
+ * 60 Hz loop over a world where one ship moved does one update.
34
+ */
35
+ /** The subset of `THREE.Object3D` a scene needs to expose. */
36
+ export interface SceneGraphLike<O> {
37
+ add(child: O): unknown;
38
+ remove(child: O): unknown;
39
+ }
40
+ /** The subset of a store this binding reads. */
41
+ export interface StoreLike<S> {
42
+ getState(): S;
43
+ subscribe(listener: (state: S, previous: S) => void): () => void;
44
+ }
45
+ /** How one kind of entity becomes, and stays, an object in the scene. */
46
+ export interface EntityBinding<E, O> {
47
+ /**
48
+ * Build the object for an entity that has just appeared.
49
+ *
50
+ * Called once per id. Add it to the scene yourself only if you need
51
+ * a parent other than the one passed to {@link bindEntities}.
52
+ */
53
+ create(entity: E, id: string): O;
54
+ /**
55
+ * Bring an existing object up to date.
56
+ *
57
+ * Called only when the entity's REFERENCE changed, which is the
58
+ * whole point: the store shares unchanged subtrees, so an entity
59
+ * nothing touched costs nothing here.
60
+ */
61
+ update(object: O, entity: E, id: string): void;
62
+ /**
63
+ * Let go of an object whose entity is gone.
64
+ *
65
+ * The binding removes it from the scene first. Disposing geometries
66
+ * and materials is yours, because only you know which are shared —
67
+ * and Three.js leaks both if nobody does it.
68
+ */
69
+ remove?(object: O, id: string): void;
70
+ }
71
+ export interface BindEntitiesOptions<S, E, O> {
72
+ readonly store: StoreLike<S>;
73
+ readonly scene: SceneGraphLike<O>;
74
+ /** The entities to render, by stable id. */
75
+ readonly select: (state: S) => Readonly<Record<string, E>>;
76
+ readonly binding: EntityBinding<E, O>;
77
+ /**
78
+ * Where a `create`, `update` or `remove` that throws is reported.
79
+ *
80
+ * One entity's bug must not stop the rest of the frame — the same
81
+ * convention the store's own listeners use. Defaults to
82
+ * `console.error`.
83
+ */
84
+ readonly onError?: (error: unknown, id: string) => void;
85
+ }
86
+ /** A live binding between a store and a scene. */
87
+ export interface SceneBinding<O> {
88
+ /**
89
+ * Reconcile now, against the store's current state.
90
+ *
91
+ * Called for you on every change; call it yourself from a render
92
+ * loop if you would rather pull than be pushed.
93
+ */
94
+ apply(): void;
95
+ /** The object currently rendering an entity, if any. */
96
+ object(id: string): O | undefined;
97
+ /** How many objects the binding holds. */
98
+ readonly size: number;
99
+ /** Unsubscribe and remove every object from the scene. */
100
+ dispose(): void;
101
+ }
102
+ /**
103
+ * Bind a store's entities to a scene graph.
104
+ *
105
+ * Reconciles on every published revision: new ids are created and
106
+ * added, changed ones updated, absent ones removed. An entity whose
107
+ * reference is unchanged is skipped entirely.
108
+ */
109
+ export declare function bindEntities<S, E, O>(options: BindEntitiesOptions<S, E, O>): SceneBinding<O>;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `@net-mesh/browser/three` — the store, bound to a scene graph.
3
+ *
4
+ * The adapter the Fleet demo grew, extracted because every game that
5
+ * renders a networked store writes the same twenty lines and gets the
6
+ * same two things wrong: it rebuilds objects that did not change, and
7
+ * it leaks the ones that went away.
8
+ *
9
+ * ## No runtime dependency on Three.js
10
+ *
11
+ * Nothing here imports `three`. The scene graph is a structural type —
12
+ * anything with `add` and `remove` — and the objects are whatever the
13
+ * caller's `create` returns. So the browser package's bundle does not
14
+ * grow by a renderer, and the same binding drives a Three.js scene, a
15
+ * canvas, or a test double.
16
+ *
17
+ * `three` is **not declared as a dependency of any kind** — not a
18
+ * peer dependency either, and the manifest says so by having no
19
+ * `peerDependencies` key. An earlier version of this comment called
20
+ * it an optional peer dependency, which would have had npm warn on a
21
+ * missing or mismatched `three`: a promise the manifest does not
22
+ * make. The types it would have supplied are not needed, because the
23
+ * ones here are structural.
24
+ *
25
+ * ## What it is for
26
+ *
27
+ * The store already shares the references of subtrees that did not
28
+ * change (`state.ts`'s reconciliation). That promise is worthless if
29
+ * the render layer rebuilds everything anyway, and it is exactly what
30
+ * a naive `for (const entity of Object.values(state.entities))` loop
31
+ * throws away. This binding turns it into the thing you wanted from
32
+ * it: an entity whose reference is unchanged is not touched, so a
33
+ * 60 Hz loop over a world where one ship moved does one update.
34
+ */
35
+ function report(onError, error, id) {
36
+ if (onError !== undefined) {
37
+ onError(error, id);
38
+ return;
39
+ }
40
+ // Same convention as the store's listener isolation: report it
41
+ // where a page can see it, keep the rest of the frame running.
42
+ console.error(`[net-mesh/three] entity ${id}:`, error);
43
+ }
44
+ /**
45
+ * Bind a store's entities to a scene graph.
46
+ *
47
+ * Reconciles on every published revision: new ids are created and
48
+ * added, changed ones updated, absent ones removed. An entity whose
49
+ * reference is unchanged is skipped entirely.
50
+ */
51
+ export function bindEntities(options) {
52
+ const objects = new Map();
53
+ /** The entity each object was last rendered from, by reference. */
54
+ const rendered = new Map();
55
+ let live = true;
56
+ function apply() {
57
+ if (!live)
58
+ return;
59
+ const entities = options.select(options.store.getState());
60
+ for (const id of Object.keys(entities)) {
61
+ const entity = entities[id];
62
+ const existing = objects.get(id);
63
+ if (existing === undefined) {
64
+ try {
65
+ const created = options.binding.create(entity, id);
66
+ objects.set(id, created);
67
+ rendered.set(id, entity);
68
+ options.scene.add(created);
69
+ }
70
+ catch (error) {
71
+ report(options.onError, error, id);
72
+ }
73
+ continue;
74
+ }
75
+ // The reference comparison the store's reconciliation exists to
76
+ // make possible. `Object.is`, not a deep compare: a deep
77
+ // compare per entity per frame is the cost this avoids.
78
+ if (Object.is(rendered.get(id), entity))
79
+ continue;
80
+ rendered.set(id, entity);
81
+ try {
82
+ options.binding.update(existing, entity, id);
83
+ }
84
+ catch (error) {
85
+ report(options.onError, error, id);
86
+ }
87
+ }
88
+ for (const [id, object] of [...objects]) {
89
+ if (Object.prototype.hasOwnProperty.call(entities, id))
90
+ continue;
91
+ objects.delete(id);
92
+ rendered.delete(id);
93
+ options.scene.remove(object);
94
+ try {
95
+ options.binding.remove?.(object, id);
96
+ }
97
+ catch (error) {
98
+ report(options.onError, error, id);
99
+ }
100
+ }
101
+ }
102
+ const unsubscribe = options.store.subscribe(() => {
103
+ apply();
104
+ });
105
+ apply();
106
+ return {
107
+ apply,
108
+ object: id => objects.get(id),
109
+ get size() {
110
+ return objects.size;
111
+ },
112
+ dispose: () => {
113
+ if (!live)
114
+ return;
115
+ live = false;
116
+ unsubscribe();
117
+ for (const [id, object] of [...objects]) {
118
+ objects.delete(id);
119
+ rendered.delete(id);
120
+ options.scene.remove(object);
121
+ try {
122
+ options.binding.remove?.(object, id);
123
+ }
124
+ catch (error) {
125
+ report(options.onError, error, id);
126
+ }
127
+ }
128
+ },
129
+ };
130
+ }