@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/dist/lobby.js ADDED
@@ -0,0 +1,504 @@
1
+ /**
2
+ * Lobbies: host a game others can find, list the open ones, join by
3
+ * code or link.
4
+ *
5
+ * ```ts
6
+ * const lobby = await createLobby({ node, game: 'arena', name: 'Friday arena', capacity: 8,
7
+ * definition, initialState, actions, inputs, project });
8
+ * lobby.code; // 'K7QP2M' — say it out loud, or share lobby.link()
9
+ * const me = lobby.self; // the host's own player, same shape as a joined one
10
+ *
11
+ * const open = await listLobbies({ node, game: 'arena' });
12
+ * const world = await joinLobby({ node, definition, code: 'K7QP2M' });
13
+ * ```
14
+ *
15
+ * A thin layer over what already exists — `hostStore`, `hostPlayer`,
16
+ * `joinStore`, `announce` and `query` — and no new protocol. A lobby is
17
+ * found through capability tags, which are free-form strings the mesh
18
+ * carries in a node's signed announcement:
19
+ *
20
+ * - `net-lobby:<game>` — the listing tag; public lobbies only.
21
+ * - `net-lobby:<game>:rec:<base64url JSON>` — the record a list shows:
22
+ * code, name, players, capacity, store version and the game's own
23
+ * `info`. Public lobbies only.
24
+ * - `net-lobby:<game>:seek:<host hex>` — announced by a player joining that
25
+ * host. A browser node accepts a relayed handshake only from a peer whose
26
+ * signed announcement it holds, and the anchor delivers announcements to
27
+ * its peers — so a joiner that announces nothing cannot reach the host
28
+ * (measured: `examples/anchor-acceptance`). The tag says why this player
29
+ * announces at all.
30
+ * - `net-lobby:<game>:code:<32 hex>` — the code, hashed; how a code is
31
+ * looked up. Both kinds announce it.
32
+ *
33
+ * **What a listing is.** Anyone on the mesh can announce any tag, so a
34
+ * record is a claim. The host id beside it is not: it comes from the
35
+ * signed announcement. Records are validated and bounded before they
36
+ * are shown, and joining connects to the announcing node, whose store
37
+ * then answers for itself.
38
+ *
39
+ * **Unlisted is not secret.** An unlisted lobby publishes no record, so
40
+ * it never appears in a list — but a 6-character code can be guessed
41
+ * against its hash, and the anchor relays every announcement. Who may
42
+ * join is decided by `authorize`, as for any store.
43
+ *
44
+ * **One lobby per node.** `announce` replaces a node's tags, so the
45
+ * lobby owns its node's announcement while it is open; pass any other
46
+ * tags the node should keep announcing as `tags`.
47
+ */
48
+ import { StoreError } from './store/errors.js';
49
+ import { hostInternals, hostStore, } from './store/host.js';
50
+ import { joinStore } from './store/join.js';
51
+ import { hostPlayer } from './store/player.js';
52
+ export class LobbyError extends Error {
53
+ code;
54
+ constructor(code, message) {
55
+ super(message);
56
+ this.code = code;
57
+ this.name = 'LobbyError';
58
+ }
59
+ }
60
+ /** Bounds on what a lobby may publish (Q4 in the lobbies plan). */
61
+ export const MAX_LOBBY_NAME_CHARS = 64;
62
+ export const MAX_LOBBY_INFO_BYTES = 256;
63
+ export const MAX_LOBBY_RECORD_TAG_BYTES = 512;
64
+ export const MAX_LOBBY_CAPACITY = 256;
65
+ /** Characters a code is made of: no 0/O, 1/I/L, so a code survives being read aloud. */
66
+ export const LOBBY_CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
67
+ export const LOBBY_CODE_LENGTH = 6;
68
+ /** How often a lobby re-announces itself. */
69
+ export const LOBBY_ANNOUNCE_MS = 2_000;
70
+ /** The frame bound the package's own tests and demo use. */
71
+ const DEFAULT_MAX_EVENT_BYTES = 8104;
72
+ const GAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
73
+ const RECORD_VERSION = 1;
74
+ const encoder = new TextEncoder();
75
+ const decoder = new TextDecoder();
76
+ function checkGame(game) {
77
+ if (!GAME.test(game)) {
78
+ throw new LobbyError('invalid', `a game name is 1-64 of a-z 0-9 . _ - (starting with a letter or digit), got ${JSON.stringify(game)}`);
79
+ }
80
+ }
81
+ /** A code as typed: case, spaces and dashes forgiven. */
82
+ export function normalizeLobbyCode(code) {
83
+ const cleaned = code.toUpperCase().replace(/[\s-]/g, '');
84
+ if (cleaned.length !== LOBBY_CODE_LENGTH || [...cleaned].some(c => !LOBBY_CODE_ALPHABET.includes(c))) {
85
+ throw new LobbyError('invalid', `not a lobby code: ${JSON.stringify(code)}`);
86
+ }
87
+ return cleaned;
88
+ }
89
+ function randomCode() {
90
+ // Rejection sampling, so every character is equally likely.
91
+ const limit = 256 - (256 % LOBBY_CODE_ALPHABET.length);
92
+ let code = '';
93
+ while (code.length < LOBBY_CODE_LENGTH) {
94
+ const bytes = new Uint8Array(16);
95
+ crypto.getRandomValues(bytes);
96
+ for (const byte of bytes) {
97
+ if (byte < limit && code.length < LOBBY_CODE_LENGTH)
98
+ code += LOBBY_CODE_ALPHABET[byte % LOBBY_CODE_ALPHABET.length];
99
+ }
100
+ }
101
+ return code;
102
+ }
103
+ /** A player joining `host` announces this, so the host can discover it. */
104
+ function seekTag(game, host) {
105
+ return `net-lobby:${game}:seek:${host}`;
106
+ }
107
+ function listingTag(game) {
108
+ return `net-lobby:${game}`;
109
+ }
110
+ function recordPrefix(game) {
111
+ return `net-lobby:${game}:rec:`;
112
+ }
113
+ async function codeTag(game, code) {
114
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(`net-lobby\u001f${game}\u001f${code}`)));
115
+ let hex = '';
116
+ for (const byte of digest.subarray(0, 16))
117
+ hex += byte.toString(16).padStart(2, '0');
118
+ return `net-lobby:${game}:code:${hex}`;
119
+ }
120
+ function base64url(bytes) {
121
+ let binary = '';
122
+ for (const byte of bytes)
123
+ binary += String.fromCharCode(byte);
124
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
125
+ }
126
+ function fromBase64url(text) {
127
+ if (!/^[A-Za-z0-9_-]*$/.test(text))
128
+ return null;
129
+ try {
130
+ const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
131
+ return Uint8Array.from(binary, c => c.charCodeAt(0));
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ function checkInfo(info) {
138
+ if (typeof info !== 'object' || info === null || Array.isArray(info)) {
139
+ throw new LobbyError('invalid', 'lobby info is a flat object of strings, numbers and booleans');
140
+ }
141
+ const out = {};
142
+ for (const [key, value] of Object.entries(info)) {
143
+ if (typeof value === 'number' ? !Number.isFinite(value) : typeof value !== 'string' && typeof value !== 'boolean') {
144
+ throw new LobbyError('invalid', `lobby info.${key} must be a string, a finite number or a boolean`);
145
+ }
146
+ out[key] = value;
147
+ }
148
+ if (encoder.encode(JSON.stringify(out)).length > MAX_LOBBY_INFO_BYTES) {
149
+ throw new LobbyError('invalid', `lobby info is over ${MAX_LOBBY_INFO_BYTES} bytes of JSON`);
150
+ }
151
+ return Object.freeze(out);
152
+ }
153
+ function checkName(name) {
154
+ if (typeof name !== 'string' || name.trim().length === 0 || [...name].length > MAX_LOBBY_NAME_CHARS) {
155
+ throw new LobbyError('invalid', `a lobby name is 1-${MAX_LOBBY_NAME_CHARS} characters`);
156
+ }
157
+ return name;
158
+ }
159
+ function checkCapacity(capacity) {
160
+ if (typeof capacity !== 'number' || !Number.isInteger(capacity) || capacity < 1 || capacity > MAX_LOBBY_CAPACITY) {
161
+ throw new LobbyError('invalid', `capacity is a whole number from 1 to ${MAX_LOBBY_CAPACITY}`);
162
+ }
163
+ return capacity;
164
+ }
165
+ function encodeRecord(game, record) {
166
+ const json = JSON.stringify({
167
+ v: RECORD_VERSION,
168
+ c: record.code,
169
+ n: record.name,
170
+ p: record.players,
171
+ m: record.capacity,
172
+ s: record.store,
173
+ i: record.info,
174
+ });
175
+ const tag = recordPrefix(game) + base64url(encoder.encode(json));
176
+ if (encoder.encode(tag).length > MAX_LOBBY_RECORD_TAG_BYTES) {
177
+ throw new LobbyError('invalid', `the lobby record is over ${MAX_LOBBY_RECORD_TAG_BYTES} bytes: shorten the name or info`);
178
+ }
179
+ return tag;
180
+ }
181
+ /** A record from someone else's announcement: every field checked, or nothing. */
182
+ function decodeRecord(game, tag) {
183
+ if (encoder.encode(tag).length > MAX_LOBBY_RECORD_TAG_BYTES)
184
+ return null;
185
+ const bytes = fromBase64url(tag.slice(recordPrefix(game).length));
186
+ if (bytes === null)
187
+ return null;
188
+ let raw;
189
+ try {
190
+ raw = JSON.parse(decoder.decode(bytes));
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ if (typeof raw !== 'object' || raw === null)
196
+ return null;
197
+ const r = raw;
198
+ try {
199
+ if (r.v !== RECORD_VERSION || typeof r.c !== 'string' || typeof r.s !== 'string' || r.s.length > 200)
200
+ return null;
201
+ const capacity = checkCapacity(r.m);
202
+ const players = r.p;
203
+ if (typeof players !== 'number' || !Number.isInteger(players) || players < 0 || players > capacity)
204
+ return null;
205
+ return {
206
+ code: normalizeLobbyCode(r.c),
207
+ name: checkName(r.n),
208
+ players,
209
+ capacity,
210
+ store: r.s,
211
+ info: checkInfo(r.i ?? {}),
212
+ };
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ }
218
+ function hexPeer(peer) {
219
+ const raw = peer.startsWith('0x') ? peer.slice(2) : peer;
220
+ if (/^[0-9a-fA-F]{1,16}$/.test(raw))
221
+ return raw.toLowerCase().padStart(16, '0');
222
+ throw new LobbyError('invalid', `not a peer id: ${JSON.stringify(peer)}`);
223
+ }
224
+ /**
225
+ * Open a lobby: host the store on `node`, give the host its own
226
+ * player, and announce the lobby until it closes.
227
+ */
228
+ export async function createLobby(options) {
229
+ const { node, game } = options;
230
+ checkGame(game);
231
+ const name = checkName(options.name);
232
+ const capacity = checkCapacity(options.capacity);
233
+ const info = checkInfo(options.info ?? {});
234
+ const visibility = options.visibility ?? 'public';
235
+ const code = randomCode();
236
+ const store = `${options.definition.id}@${options.definition.version}`;
237
+ const lookup = await codeTag(game, code);
238
+ // Refuse an unpublishable record now, where the mistake is legible,
239
+ // rather than from inside the first announcement.
240
+ encodeRecord(game, { code, name, players: capacity, capacity, store, info });
241
+ const self = node.nodeIdHex();
242
+ if (self === null)
243
+ throw new LobbyError('invalid', 'the node has no id yet: connect it before opening a lobby');
244
+ const selfHex = hexPeer(self);
245
+ const kicked = new Set();
246
+ const policy = options.authorize ?? (() => true);
247
+ // Assigned right after the store exists; `authorize` is not called
248
+ // before a join arrives.
249
+ let peersNow = () => [];
250
+ const authorize = (request) => {
251
+ const peer = request.peer;
252
+ if (kicked.has(peer))
253
+ return false;
254
+ if (request.type === 'read' && peer !== selfHex && !peersNow().includes(peer)) {
255
+ // A NEW player: is there room? The host is one of `capacity`.
256
+ const others = peersNow().filter(existing => existing !== selfHex).length;
257
+ if (1 + others >= capacity)
258
+ return false;
259
+ }
260
+ return policy(request);
261
+ };
262
+ const { audience, tags, announceEveryMs, maxEventBytes } = options;
263
+ // The store's own options pass through untouched; the lobby's are
264
+ // not the store's business.
265
+ const { node: _node, game: _game, name: _name, capacity: _capacity, visibility: _visibility, info: _info, audience: _audience, tags: _tags, announceEveryMs: _every, maxEventBytes: _max, authorize: _policy, ...storeOptions } = options;
266
+ const host = hostStore({
267
+ ...storeOptions,
268
+ transport: node,
269
+ maxEventBytes: maxEventBytes ?? DEFAULT_MAX_EVENT_BYTES,
270
+ authorize,
271
+ });
272
+ const inner = hostInternals(host);
273
+ peersNow = () => inner.owner.peers();
274
+ const me = hostPlayer(host, { audience: audience ?? [] });
275
+ function players() {
276
+ return [selfHex, ...inner.owner.peers().filter(peer => peer !== selfHex)];
277
+ }
278
+ const listeners = new Set();
279
+ let closed = false;
280
+ let announcing = null;
281
+ let again = false;
282
+ function currentTags() {
283
+ const list = [...(tags ?? []), lookup];
284
+ if (visibility === 'public') {
285
+ list.push(listingTag(game), encodeRecord(game, { code, name, players: players().length, capacity, store, info }));
286
+ }
287
+ return list;
288
+ }
289
+ /** One announcement at a time; a change during one is announced after it. */
290
+ function announce() {
291
+ if (closed)
292
+ return;
293
+ if (announcing !== null) {
294
+ again = true;
295
+ return;
296
+ }
297
+ announcing = node
298
+ .announce(currentTags())
299
+ .catch(() => {
300
+ // The next tick tries again; a lobby that cannot announce is
301
+ // simply not found, which the joiner reports as `not-found`.
302
+ })
303
+ .finally(() => {
304
+ announcing = null;
305
+ if (again) {
306
+ again = false;
307
+ announce();
308
+ }
309
+ });
310
+ }
311
+ let last = players().join();
312
+ const stopMembership = inner.owner.onMembership(() => {
313
+ // Inside the owner's frame: read, compare, and defer everything
314
+ // else to a later turn.
315
+ queueMicrotask(() => {
316
+ if (closed)
317
+ return;
318
+ const now = players();
319
+ if (now.join() === last)
320
+ return;
321
+ last = now.join();
322
+ announce();
323
+ for (const listener of [...listeners]) {
324
+ try {
325
+ listener(now);
326
+ }
327
+ catch {
328
+ // A UI listener's failure is not the lobby's.
329
+ }
330
+ }
331
+ });
332
+ });
333
+ announce();
334
+ const timer = setInterval(announce, announceEveryMs ?? LOBBY_ANNOUNCE_MS);
335
+ return {
336
+ code,
337
+ game,
338
+ visibility,
339
+ host,
340
+ self: me,
341
+ players,
342
+ subscribePlayers: listener => {
343
+ listeners.add(listener);
344
+ return () => {
345
+ listeners.delete(listener);
346
+ };
347
+ },
348
+ link: base => {
349
+ const url = new URL(base ?? globalThis.location?.href ?? 'about:blank');
350
+ url.searchParams.set('lobby', code);
351
+ return url.toString();
352
+ },
353
+ kick: peer => {
354
+ const target = hexPeer(peer);
355
+ if (target === selfHex)
356
+ throw new LobbyError('invalid', 'the host cannot kick itself; close the lobby instead');
357
+ kicked.add(target);
358
+ inner.dispatched(inner.owner.reauthorize());
359
+ },
360
+ close: async () => {
361
+ if (closed)
362
+ return;
363
+ closed = true;
364
+ clearInterval(timer);
365
+ stopMembership();
366
+ listeners.clear();
367
+ await me.close();
368
+ await host.close();
369
+ // Withdraw the lobby: announce only what the node keeps.
370
+ await node.announce([...(tags ?? [])]).catch(() => { });
371
+ },
372
+ };
373
+ }
374
+ /** The open public lobbies of `game`, as their hosts last announced them. */
375
+ export async function listLobbies(options) {
376
+ checkGame(options.game);
377
+ const prefix = recordPrefix(options.game);
378
+ const found = [];
379
+ const seen = new Set();
380
+ for (const descriptor of await options.node.query(listingTag(options.game))) {
381
+ if (seen.has(descriptor.peerIdHex))
382
+ continue;
383
+ const tag = descriptor.capabilities.find(candidate => candidate.startsWith(prefix));
384
+ if (tag === undefined)
385
+ continue;
386
+ const record = decodeRecord(options.game, tag);
387
+ if (record === null)
388
+ continue;
389
+ seen.add(descriptor.peerIdHex);
390
+ found.push({ ...record, host: descriptor.peerIdHex });
391
+ }
392
+ return found;
393
+ }
394
+ /**
395
+ * Join a lobby by code or by listing. Resolves once the host is found
396
+ * and the join is sent; await `ready()` on the result for the world.
397
+ */
398
+ export async function joinLobby(options) {
399
+ checkGame(options.game);
400
+ let host;
401
+ if (options.lobby !== undefined) {
402
+ host = hexPeer(options.lobby.host);
403
+ }
404
+ else if (options.code !== undefined) {
405
+ const tag = await codeTag(options.game, normalizeLobbyCode(options.code));
406
+ const deadline = Date.now() + (options.timeoutMs ?? 20_000);
407
+ let hosts = [];
408
+ for (;;) {
409
+ hosts = [...new Set((await options.node.query(tag)).map(descriptor => descriptor.peerIdHex))];
410
+ if (hosts.length > 0 || Date.now() >= deadline)
411
+ break;
412
+ await new Promise(resolve => setTimeout(resolve, 250));
413
+ }
414
+ if (hosts.length === 0)
415
+ throw new LobbyError('not-found', `no lobby answered to code ${options.code}`);
416
+ if (hosts.length > 1) {
417
+ // Anyone can announce any tag. Two nodes claiming one code is
418
+ // either a collision or an impersonation, and joining either
419
+ // could hand this player to the wrong host.
420
+ throw new LobbyError('ambiguous', `${hosts.length} nodes claim code ${options.code}; join from the list instead`);
421
+ }
422
+ host = hosts[0];
423
+ }
424
+ else {
425
+ throw new LobbyError('invalid', 'joinLobby needs a code or a lobby');
426
+ }
427
+ const self = options.node.nodeIdHex();
428
+ if (self !== null && hexPeer(self) === host) {
429
+ throw new StoreError('invalid-data', 'this node hosts that lobby: use lobby.self to play in it');
430
+ }
431
+ // Announce, so the host holds this player's signed key: its node
432
+ // refuses a relayed handshake from a peer it has no announcement for.
433
+ // Kept up while playing — a re-handshake (a reconnect, a replaced
434
+ // channel) needs the key again after the old announcement lapses.
435
+ const node = options.node;
436
+ const keep = [...(options.tags ?? [])];
437
+ const seeking = [...keep, seekTag(options.game, host)];
438
+ let stopped = false;
439
+ const announce = () => {
440
+ if (!stopped)
441
+ node.announce(seeking).catch(() => { });
442
+ };
443
+ announce();
444
+ const timer = setInterval(announce, LOBBY_ANNOUNCE_MS);
445
+ const stop = () => {
446
+ if (stopped)
447
+ return;
448
+ stopped = true;
449
+ clearInterval(timer);
450
+ node.announce(keep).catch(() => { });
451
+ };
452
+ // Reach the host before joining. Until this player's announcement has
453
+ // reached the host, the host refuses the handshake — so a refusal here
454
+ // is "not yet", retried until the deadline, and naming the host is
455
+ // better than the store's later `no session with 0x…`.
456
+ if (node.connectPeer !== undefined) {
457
+ const deadline = Date.now() + (options.timeoutMs ?? 20_000);
458
+ for (;;) {
459
+ try {
460
+ await node.connectPeer(host);
461
+ break;
462
+ }
463
+ catch (error) {
464
+ if (Date.now() >= deadline) {
465
+ stop();
466
+ throw new LobbyError('not-found', `could not reach the lobby's host ${host}: ${String(error?.message ?? error)}`);
467
+ }
468
+ await new Promise(resolve => setTimeout(resolve, 500));
469
+ }
470
+ }
471
+ }
472
+ let joined;
473
+ try {
474
+ joined = joinStore({
475
+ definition: options.definition,
476
+ transport: node,
477
+ host,
478
+ audience: options.audience ?? [],
479
+ key: options.key ?? 'player',
480
+ maxEventBytes: options.maxEventBytes ?? DEFAULT_MAX_EVENT_BYTES,
481
+ ...(options.interest === undefined ? {} : { interest: options.interest }),
482
+ });
483
+ }
484
+ catch (error) {
485
+ stop();
486
+ throw error;
487
+ }
488
+ const close = joined.close.bind(joined);
489
+ joined.close = async () => {
490
+ stop();
491
+ await close();
492
+ };
493
+ return joined;
494
+ }
495
+ /** The lobby code in a link made by `lobby.link()`, or `null`. Default: this page. */
496
+ export function lobbyCodeFromUrl(url) {
497
+ try {
498
+ const value = new URL(url ?? globalThis.location?.href ?? '').searchParams.get('lobby');
499
+ return value === null ? null : normalizeLobbyCode(value);
500
+ }
501
+ catch {
502
+ return null;
503
+ }
504
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `@net-mesh/browser/local` — a mesh in one page, for developing a game
3
+ * without an anchor.
4
+ *
5
+ * ```ts
6
+ * import { createLocalMesh } from '@net-mesh/browser/local';
7
+ *
8
+ * const mesh = createLocalMesh();
9
+ * const hostNode = mesh.node();
10
+ * const playerNode = mesh.node();
11
+ * const host = hostStore({ definition, transport: hostNode, … });
12
+ * const player = joinStore({ definition, transport: playerNode, host: hostNode.nodeIdHex(), … });
13
+ * ```
14
+ *
15
+ * A local node satisfies the same structural type `connect()`'s node
16
+ * does for the store, so `hostStore`, `joinStore` and `hostPlayer` are
17
+ * the real ones and every frame is really encoded, parsed, chunked,
18
+ * assembled and dispatched. What is NOT real is the network: delivery
19
+ * is a function call in this page, and the peer on each frame is
20
+ * assigned by this module rather than proved by a handshake.
21
+ *
22
+ * So a game that works here has working game logic and working store
23
+ * wiring. It is not evidence that two browsers can reach each other —
24
+ * that takes an anchor and `connect()`.
25
+ *
26
+ * Discovery works too: `announce(tags)` and `query(tag)` behave like
27
+ * the real node's, including the announcement expiring, so a lobby
28
+ * list or a find-the-host loop can be built offline as well.
29
+ *
30
+ * Nothing here loads the wasm node: importing this subpath costs no
31
+ * download.
32
+ */
33
+ import type { NodeDescriptor } from './node.js';
34
+ import type { StoreTransport } from './store/host.js';
35
+ /**
36
+ * How long an announcement stays discoverable: the leaf's default,
37
+ * 300 s (`net-mesh-leaf` `DEFAULT_TTL_SECS`). A node that announces
38
+ * once and never again drops out of `query` after it, as on the mesh.
39
+ */
40
+ export declare const LOCAL_ANNOUNCEMENT_TTL_MS = 300000;
41
+ /** Options for {@link createLocalMesh}. */
42
+ export interface LocalMeshOptions {
43
+ /** Announcement lifetime, milliseconds. Default {@link LOCAL_ANNOUNCEMENT_TTL_MS}. */
44
+ readonly announcementTtlMs?: number;
45
+ /** The clock announcements are judged against. Default `Date.now`. */
46
+ readonly now?: () => number;
47
+ }
48
+ /** A node on a local mesh. Pass it wherever the store takes a `transport`. */
49
+ export interface LocalNode extends StoreTransport {
50
+ /** This node's id: 16 lowercase hex, as `connect()`'s node reports it. */
51
+ nodeIdHex(): string;
52
+ /**
53
+ * Publish this node's capability tags, replacing any it announced
54
+ * before — as `connect()`'s node does. Discoverable by the OTHER
55
+ * nodes on this mesh until the announcement expires.
56
+ */
57
+ announce(capabilities: readonly string[]): Promise<void>;
58
+ /**
59
+ * The other nodes whose fresh announcement carries `capability`, in
60
+ * the same descriptor shape `connect()`'s node returns. This node's
61
+ * own announcement is not included, as a leaf does not hear itself.
62
+ */
63
+ query(capability: string): Promise<NodeDescriptor[]>;
64
+ /** Take this node off the mesh: frames to it fail, and it hears nothing. */
65
+ close(): void;
66
+ }
67
+ /** One page's worth of mesh. Meshes are independent of each other. */
68
+ export interface LocalMesh {
69
+ /**
70
+ * Add a node. `id` is 16 hex digits; omitted, one is chosen at
71
+ * random. Refuses an id already on this mesh.
72
+ */
73
+ node(id?: string): LocalNode;
74
+ /** The ids of the nodes currently on this mesh. */
75
+ nodes(): readonly string[];
76
+ /** Take every node off the mesh. */
77
+ close(): void;
78
+ }
79
+ /** Create an empty local mesh. */
80
+ export declare function createLocalMesh(options?: LocalMeshOptions): LocalMesh;