@net-mesh/browser 0.42.0 → 0.44.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 +200 -0
- package/README.md +12 -0
- package/dist/index.bundle.js +2 -2
- package/dist/leader/session.d.ts +4 -2
- package/dist/leader/session.js +30 -6
- package/dist/lobby.d.ts +29 -2
- package/dist/lobby.js +80 -10
- package/dist/net_leaf.d.ts +19 -21
- package/dist/net_leaf.js +42 -44
- package/dist/net_leaf_bg.wasm +0 -0
- package/dist/net_leaf_bg.wasm.d.ts +10 -10
- package/dist/netcode/assignment.d.ts +169 -0
- package/dist/netcode/assignment.js +321 -0
- package/dist/netcode/client.d.ts +125 -3
- package/dist/netcode/client.js +291 -27
- package/dist/netcode/codec.d.ts +180 -0
- package/dist/netcode/codec.js +547 -0
- package/dist/netcode/host.d.ts +58 -1
- package/dist/netcode/host.js +254 -9
- package/dist/netcode/index.d.ts +14 -4
- package/dist/netcode/index.js +8 -3
- package/dist/netcode/interpolate.d.ts +24 -1
- package/dist/netcode/interpolate.js +52 -1
- package/dist/netcode/paths.d.ts +115 -0
- package/dist/netcode/paths.js +261 -0
- package/dist/netcode/shards.d.ts +138 -0
- package/dist/netcode/shards.js +270 -0
- package/dist/netcode/wire.d.ts +37 -3
- package/dist/netcode/wire.js +28 -1
- package/dist/node.d.ts +20 -0
- package/dist/node.js +76 -1
- package/dist/store/host.js +10 -4
- package/dist/store/join.js +38 -2
- package/dist/store/owner.d.ts +11 -0
- package/dist/store/owner.js +27 -7
- package/dist/store/types.d.ts +6 -0
- package/dist/stream.d.ts +29 -1
- package/dist/stream.js +66 -4
- package/dist/udp-probe.d.ts +21 -3
- package/dist/udp-probe.js +78 -7
- package/dist/world/link.d.ts +9 -0
- package/dist/world/link.js +4 -3
- package/dist/world/regions.d.ts +9 -2
- package/dist/world/regions.js +34 -4
- package/package.json +3 -2
package/dist/stream.js
CHANGED
|
@@ -17,13 +17,24 @@ export class StreamIdentityError extends SessionError {
|
|
|
17
17
|
this.name = 'StreamIdentityError';
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
/** Most frames a {@link LeafStream} buffers before its first consumer. */
|
|
21
|
+
export const LEAF_STREAM_PENDING_MAX_FRAMES = 1024;
|
|
22
|
+
/** Most bytes a {@link LeafStream} buffers before its first consumer. */
|
|
23
|
+
export const LEAF_STREAM_PENDING_MAX_BYTES = 1 << 20;
|
|
20
24
|
/**
|
|
21
25
|
* One open stream.
|
|
22
26
|
*
|
|
23
27
|
* Inbound payloads are delivered to `for await` consumers and to
|
|
24
28
|
* {@link LeafStream.onMessage} listeners. Payloads that arrive before
|
|
25
29
|
* anyone consumes them are buffered in arrival order, so a page that
|
|
26
|
-
* opens a stream and awaits it a tick later loses nothing
|
|
30
|
+
* opens a stream and awaits it a tick later loses nothing — up to
|
|
31
|
+
* {@link LEAF_STREAM_PENDING_MAX_FRAMES} frames and
|
|
32
|
+
* {@link LEAF_STREAM_PENDING_MAX_BYTES} bytes. Past either bound the
|
|
33
|
+
* OLDEST buffered frame is dropped and counted in
|
|
34
|
+
* {@link LeafStream.droppedBeforeConsumer} (a single frame larger than the
|
|
35
|
+
* byte bound is never buffered, and is counted there too): a stream used only to send
|
|
36
|
+
* (the store, netcode and region-link layers read inbound traffic
|
|
37
|
+
* elsewhere) must not keep every frame the peer sends for its lifetime.
|
|
27
38
|
*/
|
|
28
39
|
export class LeafStream {
|
|
29
40
|
inner;
|
|
@@ -31,7 +42,11 @@ export class LeafStream {
|
|
|
31
42
|
queues = new Set();
|
|
32
43
|
listeners = new Set();
|
|
33
44
|
pending = [];
|
|
45
|
+
pendingBytes = 0;
|
|
46
|
+
dropped = 0;
|
|
34
47
|
closed = false;
|
|
48
|
+
/** Why the SESSION closed this stream, when it did so for a leader change. */
|
|
49
|
+
retiredBy = null;
|
|
35
50
|
/**
|
|
36
51
|
* This stream's wire id as a number, or `null` when the inner
|
|
37
52
|
* object's `stream_id_hex()` was not hex.
|
|
@@ -94,6 +109,13 @@ export class LeafStream {
|
|
|
94
109
|
}
|
|
95
110
|
inner.on_message((event) => this.receive(event));
|
|
96
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Frames dropped from the pre-consumer buffer because no `for await`
|
|
114
|
+
* consumer or `onMessage` listener was attached and it was full.
|
|
115
|
+
*/
|
|
116
|
+
get droppedBeforeConsumer() {
|
|
117
|
+
return this.dropped;
|
|
118
|
+
}
|
|
97
119
|
/** The authenticated peer this stream is with, 16 lowercase hex. */
|
|
98
120
|
get peerNode() {
|
|
99
121
|
return this.inner.peer_node_hex();
|
|
@@ -113,7 +135,7 @@ export class LeafStream {
|
|
|
113
135
|
/** Send one payload. Fragmentation at `MAX_PAYLOAD_SIZE` is the leaf's. */
|
|
114
136
|
async send(payload) {
|
|
115
137
|
if (this.closed)
|
|
116
|
-
throw new SessionError('the stream is closed');
|
|
138
|
+
throw new SessionError(this.retiredBy ?? 'the stream is closed');
|
|
117
139
|
try {
|
|
118
140
|
await this.inner.send(payload);
|
|
119
141
|
}
|
|
@@ -124,7 +146,7 @@ export class LeafStream {
|
|
|
124
146
|
/** Listen for payloads. Returns a cancel handle. */
|
|
125
147
|
onMessage(listener) {
|
|
126
148
|
this.listeners.add(listener);
|
|
127
|
-
const buffered = this.
|
|
149
|
+
const buffered = this.drainPending();
|
|
128
150
|
for (const payload of buffered)
|
|
129
151
|
listener(payload);
|
|
130
152
|
return () => {
|
|
@@ -134,13 +156,26 @@ export class LeafStream {
|
|
|
134
156
|
[Symbol.asyncIterator]() {
|
|
135
157
|
const queue = new AsyncQueue(() => this.queues.delete(queue));
|
|
136
158
|
this.queues.add(queue);
|
|
137
|
-
const buffered = this.
|
|
159
|
+
const buffered = this.drainPending();
|
|
138
160
|
for (const payload of buffered)
|
|
139
161
|
queue.push(payload);
|
|
140
162
|
if (this.closed)
|
|
141
163
|
queue.end();
|
|
142
164
|
return queue;
|
|
143
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* @internal — the session closing this stream because the leader it
|
|
168
|
+
* was opened on is gone. Later sends fail with the leaf's own
|
|
169
|
+
* reopenable wording ("stale stream handle"), so a store that cached
|
|
170
|
+
* the stream reopens it on the new leader instead of failing every
|
|
171
|
+
* frame forever (SDK audit 2026-10-11).
|
|
172
|
+
*/
|
|
173
|
+
retire(reason) {
|
|
174
|
+
if (this.closed)
|
|
175
|
+
return;
|
|
176
|
+
this.retiredBy = reason;
|
|
177
|
+
this.close();
|
|
178
|
+
}
|
|
144
179
|
/** Close the stream and end every consumer. */
|
|
145
180
|
close() {
|
|
146
181
|
if (this.closed)
|
|
@@ -163,12 +198,39 @@ export class LeafStream {
|
|
|
163
198
|
* taken as an already-decoded payload, which is what a
|
|
164
199
|
* host-supplied {@link LeafWasmStreamLike} may deliver.
|
|
165
200
|
*/
|
|
201
|
+
/**
|
|
202
|
+
* Hand the pre-consumer buffer over, emptying it — count included. The
|
|
203
|
+
* byte count stayed at the drained total, so after a consumer drained the
|
|
204
|
+
* buffer and detached, rebuffering tripped the byte cap almost at once
|
|
205
|
+
* and evicted all but the newest frame on nearly every arrival (code
|
|
206
|
+
* review 2026-10-11, BROWSER-1).
|
|
207
|
+
*/
|
|
208
|
+
drainPending() {
|
|
209
|
+
this.pendingBytes = 0;
|
|
210
|
+
return this.pending.splice(0, this.pending.length);
|
|
211
|
+
}
|
|
166
212
|
receive(event) {
|
|
167
213
|
const payload = this.decode(event);
|
|
168
214
|
if (payload === null || this.closed)
|
|
169
215
|
return;
|
|
170
216
|
if (this.queues.size === 0 && this.listeners.size === 0) {
|
|
217
|
+
// A single frame over the byte cap is dropped and counted rather
|
|
218
|
+
// than buffered: the eviction loop below stops at one frame, so it
|
|
219
|
+
// would otherwise sit there past the cap (cubic review of PR #1228).
|
|
220
|
+
if (payload.byteLength > LEAF_STREAM_PENDING_MAX_BYTES) {
|
|
221
|
+
this.dropped += 1;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
171
224
|
this.pending.push(payload);
|
|
225
|
+
this.pendingBytes += payload.byteLength;
|
|
226
|
+
// Bounded (SDK audit 2026-10-11): unbounded, an unread stream grew
|
|
227
|
+
// at the peer's send rate until the tab ran out of memory.
|
|
228
|
+
while (this.pending.length > LEAF_STREAM_PENDING_MAX_FRAMES ||
|
|
229
|
+
this.pendingBytes > LEAF_STREAM_PENDING_MAX_BYTES) {
|
|
230
|
+
const oldest = this.pending.shift();
|
|
231
|
+
this.pendingBytes -= oldest.byteLength;
|
|
232
|
+
this.dropped += 1;
|
|
233
|
+
}
|
|
172
234
|
return;
|
|
173
235
|
}
|
|
174
236
|
for (const listener of this.listeners) {
|
package/dist/udp-probe.d.ts
CHANGED
|
@@ -53,7 +53,12 @@ export type StunProbeOutcome =
|
|
|
53
53
|
readonly type: 'unanswered';
|
|
54
54
|
readonly detail: string;
|
|
55
55
|
}
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* The probe established nothing either way: it was not run (no address
|
|
58
|
+
* to aim at, disabled, or the connection failed before gathering), or
|
|
59
|
+
* it ran and was inconclusive (gathering completed with neither an
|
|
60
|
+
* answer nor a "no response" report). Never evidence of blocked UDP.
|
|
61
|
+
*/
|
|
57
62
|
| {
|
|
58
63
|
readonly type: 'notRun';
|
|
59
64
|
readonly reason: string;
|
|
@@ -76,6 +81,10 @@ export type ProbeEvent = {
|
|
|
76
81
|
/** The probe connection's ICE state became `failed`. */
|
|
77
82
|
| {
|
|
78
83
|
readonly ice: 'failed';
|
|
84
|
+
}
|
|
85
|
+
/** Gathering completed (the end-of-candidates signal). */
|
|
86
|
+
| {
|
|
87
|
+
readonly gathering: 'complete';
|
|
79
88
|
};
|
|
80
89
|
/**
|
|
81
90
|
* Is this candidate line server-reflexive? The `typ` token followed by
|
|
@@ -98,10 +107,19 @@ export declare function probeEventAnswers(event: ProbeEvent): boolean;
|
|
|
98
107
|
* event answers, `notRun` when the engine failed the connection before
|
|
99
108
|
* gathering a single candidate (nothing was sent, so silence is not
|
|
100
109
|
* evidence — measured: Firefox without a media permission on an
|
|
101
|
-
* IPv6-only network with no IPv4 route)
|
|
102
|
-
*
|
|
110
|
+
* IPv6-only network with no IPv4 route) or when gathering completed with
|
|
111
|
+
* neither an answer nor a "no response" report (inconclusive),
|
|
112
|
+
* `unanswered` otherwise. The Rust probe's `probe_verdict`, the same
|
|
113
|
+
* three answers.
|
|
103
114
|
*/
|
|
104
115
|
export declare function stunProbeVerdict(events: readonly ProbeEvent[]): 'answered' | 'unanswered' | 'notRun';
|
|
116
|
+
/**
|
|
117
|
+
* Is this a `typ host` candidate whose address is a literal publicly
|
|
118
|
+
* routable IP (not private, loopback, link-local, CGNAT, unique-local or
|
|
119
|
+
* unspecified)? An obfuscated `.local` address is not known public. Mirrors
|
|
120
|
+
* the Rust `host_candidate_is_public`.
|
|
121
|
+
*/
|
|
122
|
+
export declare function hostCandidateIsPublic(line: string): boolean;
|
|
105
123
|
/** {@link probeEventAnswers} over everything one probe reported. */
|
|
106
124
|
export declare function stunProbeAnswered(events: readonly ProbeEvent[]): boolean;
|
|
107
125
|
/** One endpoint and what probing it established. */
|
package/dist/udp-probe.js
CHANGED
|
@@ -61,15 +61,71 @@ export function probeEventAnswers(event) {
|
|
|
61
61
|
* event answers, `notRun` when the engine failed the connection before
|
|
62
62
|
* gathering a single candidate (nothing was sent, so silence is not
|
|
63
63
|
* evidence — measured: Firefox without a media permission on an
|
|
64
|
-
* IPv6-only network with no IPv4 route)
|
|
65
|
-
*
|
|
64
|
+
* IPv6-only network with no IPv4 route) or when gathering completed with
|
|
65
|
+
* neither an answer nor a "no response" report (inconclusive),
|
|
66
|
+
* `unanswered` otherwise. The Rust probe's `probe_verdict`, the same
|
|
67
|
+
* three answers.
|
|
66
68
|
*/
|
|
67
69
|
export function stunProbeVerdict(events) {
|
|
68
70
|
if (events.some(probeEventAnswers))
|
|
69
71
|
return 'answered';
|
|
70
72
|
const gathered = events.some((event) => 'candidate' in event);
|
|
71
73
|
const failed = events.some((event) => 'ice' in event && event.ice === 'failed');
|
|
72
|
-
|
|
74
|
+
if (failed && !gathered)
|
|
75
|
+
return 'notRun';
|
|
76
|
+
// Gathering that COMPLETED with no reflexive candidate and no "no
|
|
77
|
+
// response" report is inconclusive ONLY when a host candidate carried a
|
|
78
|
+
// literal public address: an engine may suppress a srflx equal to it, so
|
|
79
|
+
// a host with no NAT would otherwise read as `udp-blocked` (SDK audit
|
|
80
|
+
// 2026-10-11). Behind NAT the srflx is never suppressed, so its absence
|
|
81
|
+
// is silence — what blocked UDP looks like in Chromium (browser matrix,
|
|
82
|
+
// PR #1228). Same rule as the Rust `probe_verdict`.
|
|
83
|
+
const completed = events.some((event) => 'gathering' in event);
|
|
84
|
+
const reportedNoResponse = events.some((event) => 'error' in event && event.error >= STUN_NO_RESPONSE_CODE);
|
|
85
|
+
const publicHost = events.some((event) => 'candidate' in event && hostCandidateIsPublic(event.candidate));
|
|
86
|
+
return completed && !reportedNoResponse && publicHost ? 'notRun' : 'unanswered';
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Is this a `typ host` candidate whose address is a literal publicly
|
|
90
|
+
* routable IP (not private, loopback, link-local, CGNAT, unique-local or
|
|
91
|
+
* unspecified)? An obfuscated `.local` address is not known public. Mirrors
|
|
92
|
+
* the Rust `host_candidate_is_public`.
|
|
93
|
+
*/
|
|
94
|
+
export function hostCandidateIsPublic(line) {
|
|
95
|
+
const tokens = line.trim().split(/\s+/);
|
|
96
|
+
let host = false;
|
|
97
|
+
for (let i = 0; i + 1 < tokens.length; i += 1) {
|
|
98
|
+
if (tokens[i] === 'typ' && tokens[i + 1] === 'host')
|
|
99
|
+
host = true;
|
|
100
|
+
}
|
|
101
|
+
const addr = tokens[4];
|
|
102
|
+
if (!host || addr === undefined)
|
|
103
|
+
return false;
|
|
104
|
+
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(addr);
|
|
105
|
+
if (v4 !== null) {
|
|
106
|
+
const o = v4.slice(1).map(Number);
|
|
107
|
+
if (o.some((n) => n > 255))
|
|
108
|
+
return false;
|
|
109
|
+
const [a, b] = o;
|
|
110
|
+
const isPrivate = a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
111
|
+
const loopback = a === 127;
|
|
112
|
+
const linkLocal = a === 169 && b === 254;
|
|
113
|
+
const unspecified = o.every((n) => n === 0);
|
|
114
|
+
const broadcast = o.every((n) => n === 255);
|
|
115
|
+
const cgnat = a === 100 && (b & 0xc0) === 64;
|
|
116
|
+
return !(isPrivate || loopback || linkLocal || unspecified || broadcast || cgnat);
|
|
117
|
+
}
|
|
118
|
+
if (!addr.includes(':') || !/^[0-9a-f:.]+$/i.test(addr))
|
|
119
|
+
return false;
|
|
120
|
+
const lower = addr.toLowerCase();
|
|
121
|
+
if (lower === '::' || lower === '::1')
|
|
122
|
+
return false;
|
|
123
|
+
const first = Number.parseInt(lower.startsWith(':') ? '0' : lower.split(':')[0] ?? '0', 16);
|
|
124
|
+
if (Number.isNaN(first))
|
|
125
|
+
return false;
|
|
126
|
+
const uniqueLocal = (first & 0xfe00) === 0xfc00;
|
|
127
|
+
const linkLocalV6 = (first & 0xffc0) === 0xfe80;
|
|
128
|
+
return !(uniqueLocal || linkLocalV6);
|
|
73
129
|
}
|
|
74
130
|
/** {@link probeEventAnswers} over everything one probe reported. */
|
|
75
131
|
export function stunProbeAnswered(events) {
|
|
@@ -154,11 +210,24 @@ export async function probeStunBinding(rtcAddr, options = {}) {
|
|
|
154
210
|
// load-bearing rather than a safety net.
|
|
155
211
|
let noResponse = null;
|
|
156
212
|
const timer = setTimeout(() => settle({ type: 'unanswered', detail: noResponse ?? 'no answer inside the probe deadline' }), timeoutMs);
|
|
213
|
+
// Completion with no reflexive candidate is inconclusive only when no
|
|
214
|
+
// "no response" report came AND a host candidate carried a literal
|
|
215
|
+
// public address: libwebrtc drops a reflexive candidate equal to it, so
|
|
216
|
+
// the anchor can have answered with nothing visible. Behind NAT, or
|
|
217
|
+
// with only `.local` hosts (Firefox, which also makes no "no response"
|
|
218
|
+
// report), the srflx is never suppressed and its absence is silence
|
|
219
|
+
// (`stunProbeVerdict`, the same rule).
|
|
220
|
+
let publicHost = false;
|
|
157
221
|
const gatheringDone = () => {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
222
|
+
if (noResponse === null && publicHost) {
|
|
223
|
+
settle({
|
|
224
|
+
type: 'notRun',
|
|
225
|
+
reason: 'gathering completed beside a public host with no reflexive candidate and no "no response" report: ' +
|
|
226
|
+
'inconclusive',
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
settle({ type: 'unanswered', detail: noResponse ?? 'gathering completed with no reflexive candidate' });
|
|
162
231
|
};
|
|
163
232
|
// Did the engine gather anything at all? A connection it fails
|
|
164
233
|
// before the first candidate is one it could not gather for, and
|
|
@@ -179,6 +248,8 @@ export async function probeStunBinding(rtcAddr, options = {}) {
|
|
|
179
248
|
return;
|
|
180
249
|
}
|
|
181
250
|
gathered = true;
|
|
251
|
+
if (hostCandidateIsPublic(candidate.candidate))
|
|
252
|
+
publicHost = true;
|
|
182
253
|
// Host candidates are gathered regardless of what the network does
|
|
183
254
|
// to UDP — Chromium even hides them behind an mDNS `.local` name —
|
|
184
255
|
// so only a reflexive one is evidence.
|
package/dist/world/link.d.ts
CHANGED
|
@@ -109,7 +109,16 @@ export interface RegionHandoffsOptions<E> {
|
|
|
109
109
|
};
|
|
110
110
|
/** Outcomes: `moved`, `refused`, `unresolved` (source), `admitted` (target). */
|
|
111
111
|
onEvent?(event: HandoffEvent): void;
|
|
112
|
+
/** Entity handoffs: re-offer, give up, and how long a decision is remembered. */
|
|
112
113
|
readonly timing?: HandoffTiming;
|
|
114
|
+
/**
|
|
115
|
+
* Forwarded actions ({@link RegionHandoffs.forward}), when they need other
|
|
116
|
+
* timing than entity handoffs: a hit is worthless after half a second,
|
|
117
|
+
* while a moving entity must not be given up that soon (it would be live
|
|
118
|
+
* nowhere) nor forgotten while its offer may still be re-sent (it could
|
|
119
|
+
* be admitted twice). Default: `timing`.
|
|
120
|
+
*/
|
|
121
|
+
readonly actionTiming?: HandoffTiming;
|
|
113
122
|
/** How often to re-offer and prune, ms. Default 100. */
|
|
114
123
|
readonly tickMs?: number;
|
|
115
124
|
readonly now?: () => number;
|
package/dist/world/link.js
CHANGED
|
@@ -264,9 +264,10 @@ export function regionHandoffs(options) {
|
|
|
264
264
|
options.link.send({ to, body: frame });
|
|
265
265
|
}
|
|
266
266
|
};
|
|
267
|
-
const
|
|
268
|
-
const
|
|
269
|
-
const
|
|
267
|
+
const actionTiming = options.actionTiming ?? options.timing;
|
|
268
|
+
const retryMs = actionTiming?.retryMs ?? 250;
|
|
269
|
+
const giveUpMs = actionTiming?.giveUpMs ?? 60_000;
|
|
270
|
+
const retentionMs = actionTiming?.handledRetentionMs ?? 600_000;
|
|
270
271
|
const stop = options.link.onMessage(body => {
|
|
271
272
|
if (closed)
|
|
272
273
|
return;
|
package/dist/world/regions.d.ts
CHANGED
|
@@ -44,8 +44,15 @@ export declare function regionsAround(x: number, z: number, options: {
|
|
|
44
44
|
}): string[];
|
|
45
45
|
/** The directory tag a region host announces. */
|
|
46
46
|
export declare function regionTag(world: string, region: string): string;
|
|
47
|
-
/**
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* How often region hosts re-announce: an announcement is a lease, held
|
|
49
|
+
* for the leaf's 300 s TTL. 30 s, not 2 s, for the reason
|
|
50
|
+
* `LOBBY_ANNOUNCE_MS` gives: re-announcements flood through the anchor to
|
|
51
|
+
* every peer, and at 2 s they saturated a host's anchor link at 64 nodes.
|
|
52
|
+
* A failed announcement is retried sooner, as a lobby's is
|
|
53
|
+
* (`ANNOUNCE_RETRY_MS`, doubling up to `ANNOUNCE_RETRY_MAX_MS`).
|
|
54
|
+
*/
|
|
55
|
+
export declare const REGION_ANNOUNCE_MS = 30000;
|
|
49
56
|
/**
|
|
50
57
|
* Announce that this node hosts `regions` of `world`, and keep announcing
|
|
51
58
|
* (an announcement is a lease). `announce` replaces a node's tags, so pass
|
package/dist/world/regions.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* directory (`handoffLink({ peerOf: directory.peerOf })`) needs the list:
|
|
28
28
|
* otherwise a node that announces a region could also send offers as it.
|
|
29
29
|
*/
|
|
30
|
+
import { ANNOUNCE_RETRY_MAX_MS, ANNOUNCE_RETRY_MS } from '../lobby.js';
|
|
30
31
|
import { nodeHexOf } from '../store/host.js';
|
|
31
32
|
import { joinStore } from '../store/join.js';
|
|
32
33
|
/** Region `r:<rx>:<rz>` for a position, with regions `size` units square. */
|
|
@@ -51,8 +52,15 @@ export function regionsAround(x, z, options) {
|
|
|
51
52
|
export function regionTag(world, region) {
|
|
52
53
|
return `net-world:${world}:region:${region}`;
|
|
53
54
|
}
|
|
54
|
-
/**
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* How often region hosts re-announce: an announcement is a lease, held
|
|
57
|
+
* for the leaf's 300 s TTL. 30 s, not 2 s, for the reason
|
|
58
|
+
* `LOBBY_ANNOUNCE_MS` gives: re-announcements flood through the anchor to
|
|
59
|
+
* every peer, and at 2 s they saturated a host's anchor link at 64 nodes.
|
|
60
|
+
* A failed announcement is retried sooner, as a lobby's is
|
|
61
|
+
* (`ANNOUNCE_RETRY_MS`, doubling up to `ANNOUNCE_RETRY_MAX_MS`).
|
|
62
|
+
*/
|
|
63
|
+
export const REGION_ANNOUNCE_MS = 30_000;
|
|
56
64
|
/**
|
|
57
65
|
* Announce that this node hosts `regions` of `world`, and keep announcing
|
|
58
66
|
* (an announcement is a lease). `announce` replaces a node's tags, so pass
|
|
@@ -61,9 +69,29 @@ export const REGION_ANNOUNCE_MS = 2_000;
|
|
|
61
69
|
export function announceRegions(node, world, regions, options = {}) {
|
|
62
70
|
const tags = [...(options.tags ?? []), ...regions.map(region => regionTag(world, region))];
|
|
63
71
|
let stopped = false;
|
|
72
|
+
// A failed announcement is retried on a short backoff of its own: a
|
|
73
|
+
// region nobody can find for a whole lease refresh is a hole in the map.
|
|
74
|
+
let retry = null;
|
|
75
|
+
let retryMs = ANNOUNCE_RETRY_MS;
|
|
64
76
|
const once = () => {
|
|
65
|
-
if (
|
|
66
|
-
|
|
77
|
+
if (stopped)
|
|
78
|
+
return;
|
|
79
|
+
if (retry !== null) {
|
|
80
|
+
clearTimeout(retry);
|
|
81
|
+
retry = null;
|
|
82
|
+
}
|
|
83
|
+
void node.announce(tags).then(() => {
|
|
84
|
+
retryMs = ANNOUNCE_RETRY_MS;
|
|
85
|
+
}, () => {
|
|
86
|
+
if (stopped || retry !== null)
|
|
87
|
+
return;
|
|
88
|
+
retry = setTimeout(() => {
|
|
89
|
+
retry = null;
|
|
90
|
+
once();
|
|
91
|
+
}, retryMs);
|
|
92
|
+
retry.unref?.();
|
|
93
|
+
retryMs = Math.min(retryMs * 2, ANNOUNCE_RETRY_MAX_MS);
|
|
94
|
+
});
|
|
67
95
|
};
|
|
68
96
|
once();
|
|
69
97
|
const timer = setInterval(once, options.everyMs ?? REGION_ANNOUNCE_MS);
|
|
@@ -71,6 +99,8 @@ export function announceRegions(node, world, regions, options = {}) {
|
|
|
71
99
|
return () => {
|
|
72
100
|
stopped = true;
|
|
73
101
|
clearInterval(timer);
|
|
102
|
+
if (retry !== null)
|
|
103
|
+
clearTimeout(retry);
|
|
74
104
|
};
|
|
75
105
|
}
|
|
76
106
|
/** A region directory over the mesh's signed announcements. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@net-mesh/browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"description": "Browser-native Net mesh leaf \u2014 idiomatic TypeScript over the net-mesh-leaf wasm node",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
"check:package": "node scripts/check-package.mjs",
|
|
60
60
|
"test": "tsc -p tsconfig.test.json && vitest run",
|
|
61
61
|
"test:watch": "vitest",
|
|
62
|
-
"bench:world": "node scripts/bench-world.mjs"
|
|
62
|
+
"bench:world": "node scripts/bench-world.mjs",
|
|
63
|
+
"bench:netcode": "node scripts/bench-netcode.mjs"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
|
65
66
|
"esbuild": "^0.28.0",
|