@net-mesh/browser 0.43.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.
@@ -40,22 +40,43 @@ export interface WireInput<I> {
40
40
  readonly data: I;
41
41
  }
42
42
  export type Frame<E, I> =
43
- /** Player → host: join (repeated until the first snapshot). */
43
+ /**
44
+ * Player → host: join (repeated until the first snapshot). `f` is the
45
+ * player's entity-codec fingerprint, if it has one: the host sends binary
46
+ * snapshots only when it matches its own (`codec.ts`).
47
+ */
44
48
  {
45
49
  readonly n: string;
46
50
  readonly k: 'h';
51
+ readonly f?: string;
47
52
  }
48
- /** Player → host: the newest inputs, the unacknowledged ones repeated. */
53
+ /**
54
+ * Player → host: the newest inputs, the unacknowledged ones repeated.
55
+ * `ep`: the assignment epoch the player sent them under (sharded
56
+ * matches); a host at another epoch drops them.
57
+ */
49
58
  | {
50
59
  readonly n: string;
51
60
  readonly k: 'i';
52
61
  readonly i: readonly WireInput<I>[];
62
+ readonly ep?: number;
53
63
  }
54
- /** Player → host: clock ping. */
64
+ /** Player → host: clock ping, repeating the codec fingerprint (a lost join must not pin a player to JSON). */
55
65
  | {
56
66
  readonly n: string;
57
67
  readonly k: 'p';
58
68
  readonly t0: number;
69
+ readonly f?: string;
70
+ }
71
+ /**
72
+ * Player → host: binary-snapshot indexes it has no name for; the host
73
+ * names them again (only indexes it showed that player, and an index at
74
+ * most once per 100 ms).
75
+ */
76
+ | {
77
+ readonly n: string;
78
+ readonly k: 'u';
79
+ readonly u: readonly number[];
59
80
  }
60
81
  /**
61
82
  * Player → host: the interest keys it wants, version `v` (repeated until a
@@ -93,10 +114,23 @@ export type Frame<E, I> =
93
114
  readonly of?: number;
94
115
  /** The interest version this snapshot was filtered by, if any. */
95
116
  readonly iv?: number;
117
+ /** The assignment epoch the host sent it under (sharded matches). */
118
+ readonly ep?: number;
96
119
  };
97
120
  /** Interest keys a player may name, and the longest key, in UTF-16 units. */
98
121
  export declare const MAX_NETCODE_INTEREST_KEYS = 256;
99
122
  export declare const MAX_NETCODE_INTEREST_KEY_LENGTH = 64;
123
+ /** Indexes one `u` frame may ask to have named. */
124
+ export declare const MAX_NAME_REQUESTS = 256;
125
+ /**
126
+ * A binary-snapshot name not used for this long, ms, is forgotten: by the
127
+ * host for an id no player has been shown (it gets a fresh index if it
128
+ * comes back, named again), and by the host for one player once that
129
+ * player has not been shown it. A player keeps a name three times as long
130
+ * from the last frame that carried the index, so it never forgets one the
131
+ * host still counts as known to it; forgetting early would only cost a `u`.
132
+ */
133
+ export declare const NAME_FORGET_MS = 10000;
100
134
  /** Chunks a snapshot may be split into, at most. */
101
135
  export declare const MAX_SNAPSHOT_CHUNKS = 256;
102
136
  /**
@@ -11,6 +11,17 @@
11
11
  /** Interest keys a player may name, and the longest key, in UTF-16 units. */
12
12
  export const MAX_NETCODE_INTEREST_KEYS = 256;
13
13
  export const MAX_NETCODE_INTEREST_KEY_LENGTH = 64;
14
+ /** Indexes one `u` frame may ask to have named. */
15
+ export const MAX_NAME_REQUESTS = 256;
16
+ /**
17
+ * A binary-snapshot name not used for this long, ms, is forgotten: by the
18
+ * host for an id no player has been shown (it gets a fresh index if it
19
+ * comes back, named again), and by the host for one player once that
20
+ * player has not been shown it. A player keeps a name three times as long
21
+ * from the last frame that carried the index, so it never forgets one the
22
+ * host still counts as known to it; forgetting early would only cost a `u`.
23
+ */
24
+ export const NAME_FORGET_MS = 10_000;
14
25
  /** Chunks a snapshot may be split into, at most. */
15
26
  export const MAX_SNAPSHOT_CHUNKS = 256;
16
27
  /**
@@ -25,6 +36,10 @@ export function chunkOf(id, of) {
25
36
  }
26
37
  return (hash >>> 0) % of;
27
38
  }
39
+ /** An `ep` field: absent, or a non-negative safe integer. */
40
+ function epochField(ep) {
41
+ return ep === undefined || (Number.isSafeInteger(ep) && ep >= 0);
42
+ }
28
43
  const encoder = new TextEncoder();
29
44
  const decoder = new TextDecoder('utf-8', { fatal: true });
30
45
  export function encodeFrame(frame) {
@@ -47,11 +62,22 @@ export function decodeFrame(payload, label) {
47
62
  const frame = value;
48
63
  if (frame.n !== label || typeof frame.k !== 'string')
49
64
  return null;
65
+ const fingerprint = frame.f;
66
+ if (fingerprint !== undefined && (typeof fingerprint !== 'string' || fingerprint.length > 64))
67
+ return null;
50
68
  switch (frame.k) {
51
69
  case 'h':
52
70
  return frame;
71
+ case 'u': {
72
+ const u = frame.u;
73
+ return Array.isArray(u) && u.length <= MAX_NAME_REQUESTS && u.every(index => Number.isSafeInteger(index) && index >= 0)
74
+ ? frame
75
+ : null;
76
+ }
53
77
  case 'i':
54
- return Array.isArray(frame.i) ? frame : null;
78
+ return Array.isArray(frame.i) && epochField(frame.ep)
79
+ ? frame
80
+ : null;
55
81
  case 'p':
56
82
  return typeof frame.t0 === 'number' ? frame : null;
57
83
  case 'w': {
@@ -73,6 +99,7 @@ export function decodeFrame(payload, label) {
73
99
  case 's': {
74
100
  const s = frame;
75
101
  if ((s.iv !== undefined && !Number.isSafeInteger(s.iv)) ||
102
+ !epochField(s.ep) ||
76
103
  typeof s.tick !== 'number' ||
77
104
  typeof s.t !== 'number' ||
78
105
  typeof s.ack !== 'number' ||
package/dist/node.d.ts CHANGED
@@ -883,6 +883,11 @@ export interface RetryReport {
883
883
  readonly online: string;
884
884
  /** Triggers filed by the ICE `disconnected` → `failed` watcher. */
885
885
  readonly iceFailed: string;
886
+ /**
887
+ * Triggers filed because the far end closed a DataChannel a session
888
+ * rode. One channel's loss counts once, here or under `iceFailed`.
889
+ */
890
+ readonly channelClosed: string;
886
891
  /** Every trigger seen, whatever the owner decided. */
887
892
  readonly triggers: string;
888
893
  /** Re-attempts STARTED. One per network change, per peer. */
package/dist/node.js CHANGED
@@ -1075,6 +1075,7 @@ export function parseRetryReport(json) {
1075
1075
  armed: raw.armed === true,
1076
1076
  online: String(raw.online ?? '0'),
1077
1077
  iceFailed: String(raw.iceFailed ?? '0'),
1078
+ channelClosed: String(raw.channelClosed ?? '0'),
1078
1079
  triggers: String(raw.triggers ?? '0'),
1079
1080
  started: String(raw.started ?? '0'),
1080
1081
  coalesced: String(raw.coalesced ?? '0'),
@@ -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;
@@ -264,9 +264,10 @@ export function regionHandoffs(options) {
264
264
  options.link.send({ to, body: frame });
265
265
  }
266
266
  };
267
- const retryMs = options.timing?.retryMs ?? 250;
268
- const giveUpMs = options.timing?.giveUpMs ?? 60_000;
269
- const retentionMs = options.timing?.handledRetentionMs ?? 600_000;
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;
@@ -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
- /** How often region hosts re-announce: an announcement is a lease. */
48
- export declare const REGION_ANNOUNCE_MS = 2000;
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
@@ -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
- /** How often region hosts re-announce: an announcement is a lease. */
55
- export const REGION_ANNOUNCE_MS = 2_000;
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 (!stopped)
66
- void node.announce(tags).catch(() => undefined);
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.43.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",