@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/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
|
+
}
|
package/dist/local.d.ts
ADDED
|
@@ -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;
|