wire-mesh-core 1.56.0 → 1.58.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 (99) hide show
  1. package/dist/adapters/frame-codec.d.cts +1 -1
  2. package/dist/adapters/frame-codec.d.mts +1 -1
  3. package/dist/adapters/node-identity.d.cts +2 -2
  4. package/dist/adapters/node-identity.d.mts +2 -2
  5. package/dist/adapters/tcp-transport.d.cts +1 -1
  6. package/dist/adapters/tcp-transport.d.mts +1 -1
  7. package/dist/adapters/threshold-identity.d.cts +2 -2
  8. package/dist/adapters/threshold-identity.d.mts +2 -2
  9. package/dist/adapters/threshold-wasm.d.cts +1 -1
  10. package/dist/adapters/threshold-wasm.d.mts +1 -1
  11. package/dist/adapters/tls-transport.d.cts +1 -1
  12. package/dist/adapters/tls-transport.d.mts +1 -1
  13. package/dist/domain/async-queue.cjs +30 -0
  14. package/dist/domain/async-queue.d.cts +10 -0
  15. package/dist/domain/async-queue.d.mts +10 -0
  16. package/dist/domain/async-queue.mjs +29 -0
  17. package/dist/domain/bulk.d.cts +2 -2
  18. package/dist/domain/bulk.d.mts +2 -2
  19. package/dist/domain/capability-grant.d.cts +3 -3
  20. package/dist/domain/capability-grant.d.mts +3 -3
  21. package/dist/domain/capability-request.d.cts +2 -2
  22. package/dist/domain/capability-request.d.mts +2 -2
  23. package/dist/domain/data-sync.d.cts +2 -2
  24. package/dist/domain/data-sync.d.mts +2 -2
  25. package/dist/domain/device-id.d.cts +1 -1
  26. package/dist/domain/device-id.d.mts +1 -1
  27. package/dist/domain/direct-manage-request.d.cts +2 -2
  28. package/dist/domain/direct-manage-request.d.mts +2 -2
  29. package/dist/domain/gossip-expansion.d.cts +1 -1
  30. package/dist/domain/gossip-expansion.d.mts +1 -1
  31. package/dist/domain/gossip-extensions.cjs +4 -0
  32. package/dist/domain/gossip-extensions.d.cts +6 -0
  33. package/dist/domain/gossip-extensions.d.mts +6 -0
  34. package/dist/domain/gossip-extensions.mjs +2 -0
  35. package/dist/domain/grant-candidates.d.cts +1 -1
  36. package/dist/domain/grant-candidates.d.mts +1 -1
  37. package/dist/domain/handshake.d.cts +1 -1
  38. package/dist/domain/handshake.d.mts +1 -1
  39. package/dist/domain/mesh-session.cjs +30 -148
  40. package/dist/domain/mesh-session.d.cts +5 -3
  41. package/dist/domain/mesh-session.d.mts +5 -3
  42. package/dist/domain/mesh-session.mjs +25 -141
  43. package/dist/domain/notice-board.d.cts +3 -3
  44. package/dist/domain/notice-board.d.mts +3 -3
  45. package/dist/domain/path-trace.cjs +58 -0
  46. package/dist/domain/path-trace.d.cts +44 -0
  47. package/dist/domain/path-trace.d.mts +44 -0
  48. package/dist/domain/path-trace.mjs +52 -0
  49. package/dist/domain/ping-round-trips.cjs +38 -0
  50. package/dist/domain/ping-round-trips.d.cts +13 -0
  51. package/dist/domain/ping-round-trips.d.mts +13 -0
  52. package/dist/domain/ping-round-trips.mjs +37 -0
  53. package/dist/domain/relay-hub.d.cts +2 -2
  54. package/dist/domain/relay-hub.d.mts +2 -2
  55. package/dist/domain/relay-use-gate.d.cts +2 -2
  56. package/dist/domain/relay-use-gate.d.mts +2 -2
  57. package/dist/domain/revocation-view.d.cts +2 -2
  58. package/dist/domain/revocation-view.d.mts +2 -2
  59. package/dist/domain/room-rekey.d.cts +3 -3
  60. package/dist/domain/room-rekey.d.mts +3 -3
  61. package/dist/domain/room-token-verification.d.cts +1 -1
  62. package/dist/domain/room-token-verification.d.mts +1 -1
  63. package/dist/domain/room.d.cts +4 -4
  64. package/dist/domain/room.d.mts +4 -4
  65. package/dist/domain/shard-manifest.d.cts +1 -1
  66. package/dist/domain/shard-manifest.d.mts +1 -1
  67. package/dist/domain/tokens.d.cts +1 -1
  68. package/dist/domain/tokens.d.mts +1 -1
  69. package/dist/domain/topology-snapshot.cjs +39 -0
  70. package/dist/domain/topology-snapshot.d.cts +31 -0
  71. package/dist/domain/topology-snapshot.d.mts +31 -0
  72. package/dist/domain/topology-snapshot.mjs +37 -0
  73. package/dist/domain/topology.cjs +87 -0
  74. package/dist/domain/topology.d.cts +38 -0
  75. package/dist/domain/topology.d.mts +38 -0
  76. package/dist/domain/topology.mjs +81 -0
  77. package/dist/domain/webrtc-signaling.d.cts +2 -2
  78. package/dist/domain/webrtc-signaling.d.mts +2 -2
  79. package/dist/generated/protocol.cjs +11 -0
  80. package/dist/generated/protocol.d.cts +2 -2
  81. package/dist/generated/protocol.d.mts +2 -2
  82. package/dist/generated/protocol.mjs +10 -1
  83. package/dist/gossip-extensions-Cdy5mQhY.mjs +41 -0
  84. package/dist/gossip-extensions-Dr_T7hk_.cjs +78 -0
  85. package/dist/{identity-Br6e0Rx5.d.mts → identity-BRlM-Krs.d.cts} +1 -1
  86. package/dist/{identity-pOt5KxJB.d.cts → identity-DiuayfEd.d.mts} +1 -1
  87. package/dist/ports/identity.d.cts +1 -1
  88. package/dist/ports/identity.d.mts +1 -1
  89. package/dist/ports/transport.d.cts +1 -1
  90. package/dist/ports/transport.d.mts +1 -1
  91. package/dist/{protocol-D2Ue2nd2.d.cts → protocol-BnbpCk0Z.d.cts} +23 -1
  92. package/dist/{protocol-D2Ue2nd2.d.mts → protocol-BnbpCk0Z.d.mts} +23 -1
  93. package/dist/{room-token-verification-CNXMjIQd.d.cts → room-token-verification-D9ogJTO3.d.cts} +2 -2
  94. package/dist/{room-token-verification-ZHgJhnsu.d.mts → room-token-verification-NjaqYY4k.d.mts} +2 -2
  95. package/dist/{tokens-BtczHfrc.d.cts → tokens-BL2BzOmN.d.cts} +2 -2
  96. package/dist/{tokens-Hnq9oURj.d.mts → tokens-DtjdYS_a.d.mts} +2 -2
  97. package/dist/{transport-C5wU0UtA.d.mts → transport-BH8kGWXt.d.mts} +1 -1
  98. package/dist/{transport-fUY3alfO.d.cts → transport-Ca_ZyyZe.d.cts} +1 -1
  99. package/package.json +25 -1
@@ -1,10 +1,11 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_rolldown_runtime = require("../rolldown-runtime-VH7oDXx4.cjs");
3
2
  const require_domain_device_id = require("./device-id.cjs");
4
3
  const require_domain_handshake = require("./handshake.cjs");
4
+ const require_domain_async_queue = require("./async-queue.cjs");
5
+ const require_domain_ping_round_trips = require("./ping-round-trips.cjs");
6
+ const require_domain_topology_snapshot = require("./topology-snapshot.cjs");
7
+ const require_gossip_extensions = require("../gossip-extensions-Dr_T7hk_.cjs");
5
8
  const require_adapters_frame_codec = require("../adapters/frame-codec.cjs");
6
- let package_json = require("../../package.json");
7
- package_json = require_rolldown_runtime.__toESM(package_json, 1);
8
9
  //#region src/domain/relay-pairing.ts
9
10
  function createRelayPairings() {
10
11
  const established = /* @__PURE__ */ new Map();
@@ -12,82 +13,11 @@ function createRelayPairings() {
12
13
  has: (device) => established.has(require_domain_device_id.deviceIdToHex(device)),
13
14
  add: (device) => {
14
15
  established.set(require_domain_device_id.deviceIdToHex(device), device);
15
- }
16
- };
17
- }
18
- //#endregion
19
- //#region src/domain/ping-round-trips.ts
20
- function createPingRoundTrips() {
21
- const pending = [];
22
- function cancel(sentAt) {
23
- const index = pending.findIndex((entry) => entry.sentAt === sentAt);
24
- if (index === -1) return false;
25
- pending.splice(index, 1);
26
- return true;
27
- }
28
- return {
29
- resolveOldest(nowMs) {
30
- const entry = pending.shift();
31
- if (entry !== void 0) entry.resolve(nowMs - entry.sentAt);
32
16
  },
33
- rejectAll(reason) {
34
- for (const entry of pending.splice(0)) entry.reject(new Error(reason));
35
- },
36
- async sendAndAwait(sentAt, sendPing, timeoutMs) {
37
- const rtt = new Promise((resolve, reject) => {
38
- pending.push({
39
- sentAt,
40
- resolve,
41
- reject
42
- });
43
- });
44
- await sendPing();
45
- if (timeoutMs === void 0) return rtt;
46
- return Promise.race([rtt, new Promise((_resolve, reject) => {
47
- setTimeout(() => {
48
- if (cancel(sentAt)) reject(/* @__PURE__ */ new Error("timed out waiting for pong"));
49
- }, timeoutMs);
50
- })]);
51
- }
52
- };
53
- }
54
- //#endregion
55
- //#region src/domain/own-version.ts
56
- /** This node's own actual, currently-running wire-mesh-core version -- read once from the installed package's own package.json, never from anything gossiped or cached, so buildSelfAdvert's own self-advert extension and version.get's manage-response both always answer with the truth for this exact running process. */
57
- const OWN_VERSION = package_json.default.version;
58
- /** The gossip extension key wire-mesh-core itself injects into every self-advert (mesh-session.ts's own buildSelfAdvert) to report OWN_VERSION -- reserved so a sendGossipUpdate caller can never also supply it, separately from peer-advert's own typed struct fields (RESERVED_PEER_ADVERT_KEYS in mesh-session.ts), since this is just an extension-tail key (transport.cddl), not a struct field. */
59
- const CORE_VERSION_GOSSIP_KEY = "wire-mesh/version";
60
- const VERSION_GET_VERB = "version.get";
61
- /** True when params is the core/version domain's own version-get command (version.cddl) -- a receiver answers this unconditionally, bypassing incomingManageRequests entirely, since it is deliberately ungated: exactly as sensitive as what CORE_VERSION_GOSSIP_KEY already broadcasts unconditionally via gossip. */
62
- function isVersionGetCommand(params) {
63
- return "verb" in params && params.verb === VERSION_GET_VERB;
64
- }
65
- /** The manage-ok outcome mesh-session.ts answers a version.get request with -- OWN_VERSION on manage-ok's own open `* tstr => any` tail, the same pattern exec-list's own exec-session-info result already uses. */
66
- function versionGetOutcome() {
67
- return {
68
- result: "ok",
69
- version: OWN_VERSION
17
+ list: () => [...established.values()]
70
18
  };
71
19
  }
72
20
  //#endregion
73
- //#region src/domain/gossip-extensions.ts
74
- /** peer-advert's own three typed fields -- reserved so a `sendGossipUpdate` caller can never override the session's own device-id, address list, or freshness timestamp by supplying an extension of the same name. */
75
- const RESERVED_PEER_ADVERT_KEYS = /* @__PURE__ */ new Set([
76
- "device",
77
- "addresses",
78
- "snapshot-seconds"
79
- ]);
80
- /** A gossip extension key must be domain-qualified as `<domain>/<field>` (lowercase kebab-case each side), per `spec/CONVENTIONS.md`'s gossip-extension-namespacing convention -- this is what stops two independent applications sharing one gossip tail from silently colliding on a bare name like "status". */
81
- const GOSSIP_EXTENSION_KEY_PATTERN = /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/;
82
- /** Rejects a `sendGossipUpdate` extension bag that would either shadow one of peer-advert's own mandatory fields, use the key wire-mesh-core itself reserves for its own self-reported version, or use a bare, non-domain-qualified key -- all are caller bugs that must fail loudly at the call site, not silently corrupt or ambiguously merge into the wire frame. */
83
- function validateGossipExtensions(extensions) {
84
- for (const key of Object.keys(extensions)) {
85
- if (RESERVED_PEER_ADVERT_KEYS.has(key)) throw new Error(`sendGossipUpdate extension key "${key}" collides with a mandatory peer-advert field`);
86
- if (key === "wire-mesh/version") throw new Error(`sendGossipUpdate extension key "${key}" is reserved for wire-mesh-core's own self-reported version`);
87
- if (!GOSSIP_EXTENSION_KEY_PATTERN.test(key)) throw new Error(`sendGossipUpdate extension key "${key}" must be domain-qualified as "<domain>/<field>" (e.g. "presence/status")`);
88
- }
89
- }
90
- //#endregion
91
21
  //#region src/domain/mesh-session.ts
92
22
  const MS_PER_SECOND = 1e3;
93
23
  /** How long to wait for the node's handshake before calling it unanswered. A relay-only node never sends one; that is a state to display, not an error. */
@@ -109,20 +39,22 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
109
39
  const directory = /* @__PURE__ */ new Map();
110
40
  const frameLog = [];
111
41
  let feedCancelled = false;
112
- const eventWaiters = [];
113
- const eventBacklog = [];
42
+ const eventQueue = require_domain_async_queue.createAsyncQueue();
114
43
  let handshakeTimer = null;
115
44
  let reconnectTimer = null;
116
45
  let attempt = 0;
117
46
  let currentToken = null;
118
47
  let nextRequestId = 0;
119
48
  const relayPairings = createRelayPairings();
49
+ const topology = require_domain_topology_snapshot.createTopologySnapshotTracker({
50
+ getAuthenticatedPeer: () => connection?.peerDeviceId,
51
+ isAccepted: dial === null,
52
+ getRelayedDevices: () => relayPairings.list()
53
+ });
120
54
  const pendingManageRequests = /* @__PURE__ */ new Map();
121
- const incomingWaiters = [];
122
- const incomingBacklog = [];
123
- const revocationWaiters = [];
124
- const revocationBacklog = [];
125
- const pingRoundTrips = createPingRoundTrips();
55
+ const incomingQueue = require_domain_async_queue.createAsyncQueue();
56
+ const revocationQueue = require_domain_async_queue.createAsyncQueue();
57
+ const pingRoundTrips = require_domain_ping_round_trips.createPingRoundTrips();
126
58
  function snapshot() {
127
59
  return {
128
60
  state,
@@ -131,20 +63,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
131
63
  };
132
64
  }
133
65
  function emit() {
134
- const event = snapshot();
135
- const waiter = eventWaiters.shift();
136
- if (waiter) waiter(event);
137
- else eventBacklog.push(event);
138
- }
139
- function emitIncomingManageRequest(request) {
140
- const waiter = incomingWaiters.shift();
141
- if (waiter) waiter(request);
142
- else incomingBacklog.push(request);
143
- }
144
- function emitRevocationEntry(entry) {
145
- const waiter = revocationWaiters.shift();
146
- if (waiter) waiter(entry);
147
- else revocationBacklog.push(entry);
66
+ eventQueue.push(snapshot());
148
67
  }
149
68
  function rejectPendingManageRequests(reason) {
150
69
  for (const pending of pendingManageRequests.values()) pending.reject(new Error(reason));
@@ -210,11 +129,11 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
210
129
  emit();
211
130
  }
212
131
  };
213
- if (isVersionGetCommand(frame.command.params)) {
214
- incoming.respond(versionGetOutcome());
132
+ if (require_gossip_extensions.isVersionGetCommand(frame.command.params)) {
133
+ incoming.respond(require_gossip_extensions.versionGetOutcome());
215
134
  return;
216
135
  }
217
- emitIncomingManageRequest(incoming);
136
+ incomingQueue.push(incoming);
218
137
  }
219
138
  function applyFrame(frame) {
220
139
  if (frame.type === "relay-data") {
@@ -244,13 +163,14 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
244
163
  device: advert.device,
245
164
  advert
246
165
  });
166
+ topology.recordAdvert(advert.device);
247
167
  onPeerAdvert?.(advert);
248
168
  }
249
169
  else if (frame.type === "relay-inbound") relayPairings.add(frame["source-device"]);
250
170
  else if (frame.type === "manage-response") applyManageResponse(frame);
251
171
  else if (frame.type === "manage-request") applyManageRequest(frame);
252
172
  else if (frame.type === "pong") pingRoundTrips.resolveOldest(clock.now());
253
- else if (frame.type === "revocation-announce") for (const entry of frame.entries) emitRevocationEntry(entry);
173
+ else if (frame.type === "revocation-announce") for (const entry of frame.entries) revocationQueue.push(entry);
254
174
  }
255
175
  /** Establishes a relay-connect pairing to targetDevice if this session isn't already paired with it -- a no-op when it already is, whether that pairing was established by this session's own prior relay-connect (initiator role) or learned from an incoming relay-inbound (target role, replying back to whoever dialed it). Pairing with a new target never tears down an existing pairing with a different one: this connection can hold several simultaneously (wire-mesh#30's own multiplexed adjacency map), so a later request back to an already-paired target must not re-send relay-connect for it. relay-connect has no ack frame: the initiator proceeds to relay-data right after sending it. */
256
176
  async function ensureRelayPairing(targetDevice) {
@@ -328,14 +248,15 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
328
248
  onSessionEnd?.(link);
329
249
  if (state.status === "connected") handleDisconnect("node closed the connection", address, localDomains);
330
250
  }
331
- /** Builds this side's own self-advert: this node's own directly-reachable addresses (wire-mesh#38), or none for a caller with nothing to offer (a browser client, which cannot accept inbound connections) -- either is an honest advert, not a stopgap. extensions merge onto peer-advert's own open `* tstr => any` tail -- the mechanism sendGossipUpdate uses to keep a gossiped fact (presence status, an accept/refuse policy, or any future domain's own) live over the connection's lifetime. Extensions and CORE_VERSION_GOSSIP_KEY are spread before the mandatory fields (never after) so a caller-supplied key of the same name can never shadow them on the wire -- validateGossipExtensions already rejects both collisions loudly, but the field order is kept safe in its own right rather than relying solely on the guard staying in sync. */
251
+ /** Builds this side's own self-advert: this node's own directly-reachable addresses (wire-mesh#38), or none for a caller with nothing to offer (a browser client, which cannot accept inbound connections) -- either is an honest advert, not a stopgap. extensions merge onto peer-advert's own open `* tstr => any` tail -- the mechanism sendGossipUpdate uses to keep a gossiped fact (presence status, an accept/refuse policy, or any future domain's own) live over the connection's lifetime. Extensions, CORE_VERSION_GOSSIP_KEY, and topology/peers are all spread before the mandatory fields (never after) so a caller-supplied key of the same name can never shadow them on the wire -- validateGossipExtensions already rejects every such collision loudly, but the field order is kept safe in its own right rather than relying solely on the guard staying in sync. topology/peers (wire-mesh#180) is recomputed fresh on every call, the same "always current, never cached" treatment snapshot-seconds already gets, since a session's own connection identity and relay pairings can change between one self-advert and the next. */
332
252
  function buildSelfAdvert(extensions) {
333
- if (extensions !== void 0) validateGossipExtensions(extensions);
253
+ if (extensions !== void 0) require_gossip_extensions.validateGossipExtensions(extensions);
334
254
  return {
335
255
  type: "gossip",
336
256
  peers: [{
337
257
  ...extensions,
338
- [CORE_VERSION_GOSSIP_KEY]: OWN_VERSION,
258
+ [require_gossip_extensions.CORE_VERSION_GOSSIP_KEY]: require_gossip_extensions.OWN_VERSION,
259
+ [require_gossip_extensions.TOPOLOGY_PEERS_GOSSIP_KEY]: topology.compute(),
339
260
  device: identity.deviceId,
340
261
  addresses: [...addresses],
341
262
  "snapshot-seconds": Math.floor(clock.now() / MS_PER_SECOND)
@@ -401,51 +322,9 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
401
322
  return {
402
323
  wireUpConnection,
403
324
  session: {
404
- events: { [Symbol.asyncIterator]() {
405
- return { next: async () => new Promise((resolve) => {
406
- const backlogEvent = eventBacklog.shift();
407
- if (backlogEvent) resolve({
408
- value: backlogEvent,
409
- done: false
410
- });
411
- else eventWaiters.push((event) => {
412
- resolve({
413
- value: event,
414
- done: false
415
- });
416
- });
417
- }) };
418
- } },
419
- incomingManageRequests: { [Symbol.asyncIterator]() {
420
- return { next: async () => new Promise((resolve) => {
421
- const backlogRequest = incomingBacklog.shift();
422
- if (backlogRequest) resolve({
423
- value: backlogRequest,
424
- done: false
425
- });
426
- else incomingWaiters.push((request) => {
427
- resolve({
428
- value: request,
429
- done: false
430
- });
431
- });
432
- }) };
433
- } },
434
- revocationAnnouncements: { [Symbol.asyncIterator]() {
435
- return { next: async () => new Promise((resolve) => {
436
- const backlogEntry = revocationBacklog.shift();
437
- if (backlogEntry) resolve({
438
- value: backlogEntry,
439
- done: false
440
- });
441
- else revocationWaiters.push((entry) => {
442
- resolve({
443
- value: entry,
444
- done: false
445
- });
446
- });
447
- }) };
448
- } },
325
+ events: eventQueue.stream,
326
+ incomingManageRequests: incomingQueue.stream,
327
+ revocationAnnouncements: revocationQueue.stream,
449
328
  async connect(address, localDomains) {
450
329
  if (connection !== null) throw new Error("a session connects once; create a new one to reconnect");
451
330
  attempt = 0;
@@ -526,6 +405,9 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
526
405
  await transmit(frame, false);
527
406
  emit();
528
407
  },
408
+ getTopologyPeers() {
409
+ return topology.compute();
410
+ },
529
411
  async sendDataFrame(frame) {
530
412
  requireConnectedLink();
531
413
  frameLog.push({
@@ -1,7 +1,7 @@
1
- import { D as DeviceId, E as DataRequestFrame, N as Frame, T as DataHaveFrame, U as ManageError, V as ManageCommand, W as ManageOk, bt as RevocationEntry, et as PeerAdvert, f as CapabilityScope, lt as ProtocolVersion, p as CapabilityToken, w as DataEntriesFrame } from "../protocol-D2Ue2nd2.cjs";
2
- import { t as IdentityPort } from "../identity-pOt5KxJB.cjs";
1
+ import { D as DeviceId, E as DataRequestFrame, N as Frame, T as DataHaveFrame, U as ManageError, V as ManageCommand, W as ManageOk, bt as RevocationEntry, en as TopologyPeers, et as PeerAdvert, f as CapabilityScope, lt as ProtocolVersion, p as CapabilityToken, w as DataEntriesFrame } from "../protocol-BnbpCk0Z.cjs";
2
+ import { t as IdentityPort } from "../identity-BRlM-Krs.cjs";
3
3
  import { t as Clock } from "../clock-DiSx-WKM.cjs";
4
- import { r as Transport, t as Connection } from "../transport-fUY3alfO.cjs";
4
+ import { r as Transport, t as Connection } from "../transport-Ca_ZyyZe.cjs";
5
5
  //#region src/domain/mesh-session.d.ts
6
6
  /** How long to wait for the node's handshake before calling it unanswered. A relay-only node never sends one; that is a state to display, not an error. */
7
7
  export declare const HANDSHAKE_TIMEOUT_MS = 3000;
@@ -87,6 +87,8 @@ export interface MeshSession {
87
87
  sendRevocationAnnounce: (entries: readonly RevocationEntry[]) => Promise<void>;
88
88
  /** Re-sends this side's own self-advert with a fresh snapshot-seconds and, when given, extensions merged onto peer-advert's own open `* tstr => any` tail -- the mechanism a caller uses to keep gossiped presence status (or any other advertised fact) live over a connection's lifetime, since the initial self-advert wireUpConnection sends at connect time is otherwise never repeated. Callers own their own re-advertisement cadence (there is no timer inside MeshSession itself, matching its own DOM-free, fully unit-testable design); a caller not calling this again after connecting is exactly today's existing gossip-once-on-connect behaviour. */
89
89
  sendGossipUpdate: (extensions?: Record<string, unknown>) => Promise<void>;
90
+ /** This session's own current topology snapshot -- the identical `topology/peers` value buildSelfAdvert merges into every gossiped self-advert (wire-mesh#180), read back directly rather than only from a possibly-stale gossiped copy elsewhere in the mesh. The live, cache-bust half of the cached-vs-live split `topology.get` itself establishes: a caller answering an incoming topology.get manage-request (see topology.ts's createTopologyGetHandler) calls this to build the response. Synchronous and side-effect-free -- unlike every other method here, it sends nothing and works even before this session has ever connected (an unconnected session simply has no direct peer and no relay pairings yet, both honestly empty). */
91
+ getTopologyPeers: () => TopologyPeers;
90
92
  /** Sends one core/data frame (data-have, data-request, or data-entries) directly over this session's own connection, never relay-wrapped -- the transport half of an application's own noticeboard replication policy (data-sync.ts owns what the frames MEAN; this owns getting one onto the wire), the same layering sendRevocationAnnounce already established for its own frame kind. Rejects when not connected, exactly like every other send method here. */
91
93
  sendDataFrame: (frame: DataHaveFrame | DataRequestFrame | DataEntriesFrame) => Promise<void>;
92
94
  /** Sends a manage-request and resolves with the matching manage-response's outcome, correlated by request-id. When targetDevice is given, the request is routed to that specific peer via an established relay-connect pairing (wrapped as relay-data) rather than sent directly over this session's own Connection -- relay-hub deliberately drops manage-request/manage-response frames sent to it directly, since routing between two connected peers is not the relay role's business, so a specific peer reachable only through a relay hub can only be addressed this way. Absent, this sends directly over the Connection exactly as before. When token is given, it is attached to this one request instead of whatever setToken last set -- a single session routinely needs a different token per request when its peer shares more than one scope with this side (e.g. several core/room memberships over one connection), and a session-global token can only ever be correct for one of them. Absent, this request carries setToken's own session-global token exactly as before. When timeoutMs is given, the returned promise resolves with `{ result: "error", code: "timeout" }` rather than hanging forever if no manage-response arrives in time -- a held-open request (a human approval, a not-yet-online peer) otherwise has no way for the caller to give up on it. Absent, this request waits exactly as before, with no time limit of its own. */
@@ -1,7 +1,7 @@
1
- import { D as DeviceId, E as DataRequestFrame, N as Frame, T as DataHaveFrame, U as ManageError, V as ManageCommand, W as ManageOk, bt as RevocationEntry, et as PeerAdvert, f as CapabilityScope, lt as ProtocolVersion, p as CapabilityToken, w as DataEntriesFrame } from "../protocol-D2Ue2nd2.mjs";
2
- import { t as IdentityPort } from "../identity-Br6e0Rx5.mjs";
1
+ import { D as DeviceId, E as DataRequestFrame, N as Frame, T as DataHaveFrame, U as ManageError, V as ManageCommand, W as ManageOk, bt as RevocationEntry, en as TopologyPeers, et as PeerAdvert, f as CapabilityScope, lt as ProtocolVersion, p as CapabilityToken, w as DataEntriesFrame } from "../protocol-BnbpCk0Z.mjs";
2
+ import { t as IdentityPort } from "../identity-DiuayfEd.mjs";
3
3
  import { t as Clock } from "../clock-DiSx-WKM.mjs";
4
- import { r as Transport, t as Connection } from "../transport-C5wU0UtA.mjs";
4
+ import { r as Transport, t as Connection } from "../transport-BH8kGWXt.mjs";
5
5
  //#region src/domain/mesh-session.d.ts
6
6
  /** How long to wait for the node's handshake before calling it unanswered. A relay-only node never sends one; that is a state to display, not an error. */
7
7
  export declare const HANDSHAKE_TIMEOUT_MS = 3000;
@@ -87,6 +87,8 @@ export interface MeshSession {
87
87
  sendRevocationAnnounce: (entries: readonly RevocationEntry[]) => Promise<void>;
88
88
  /** Re-sends this side's own self-advert with a fresh snapshot-seconds and, when given, extensions merged onto peer-advert's own open `* tstr => any` tail -- the mechanism a caller uses to keep gossiped presence status (or any other advertised fact) live over a connection's lifetime, since the initial self-advert wireUpConnection sends at connect time is otherwise never repeated. Callers own their own re-advertisement cadence (there is no timer inside MeshSession itself, matching its own DOM-free, fully unit-testable design); a caller not calling this again after connecting is exactly today's existing gossip-once-on-connect behaviour. */
89
89
  sendGossipUpdate: (extensions?: Record<string, unknown>) => Promise<void>;
90
+ /** This session's own current topology snapshot -- the identical `topology/peers` value buildSelfAdvert merges into every gossiped self-advert (wire-mesh#180), read back directly rather than only from a possibly-stale gossiped copy elsewhere in the mesh. The live, cache-bust half of the cached-vs-live split `topology.get` itself establishes: a caller answering an incoming topology.get manage-request (see topology.ts's createTopologyGetHandler) calls this to build the response. Synchronous and side-effect-free -- unlike every other method here, it sends nothing and works even before this session has ever connected (an unconnected session simply has no direct peer and no relay pairings yet, both honestly empty). */
91
+ getTopologyPeers: () => TopologyPeers;
90
92
  /** Sends one core/data frame (data-have, data-request, or data-entries) directly over this session's own connection, never relay-wrapped -- the transport half of an application's own noticeboard replication policy (data-sync.ts owns what the frames MEAN; this owns getting one onto the wire), the same layering sendRevocationAnnounce already established for its own frame kind. Rejects when not connected, exactly like every other send method here. */
91
93
  sendDataFrame: (frame: DataHaveFrame | DataRequestFrame | DataEntriesFrame) => Promise<void>;
92
94
  /** Sends a manage-request and resolves with the matching manage-response's outcome, correlated by request-id. When targetDevice is given, the request is routed to that specific peer via an established relay-connect pairing (wrapped as relay-data) rather than sent directly over this session's own Connection -- relay-hub deliberately drops manage-request/manage-response frames sent to it directly, since routing between two connected peers is not the relay role's business, so a specific peer reachable only through a relay hub can only be addressed this way. Absent, this sends directly over the Connection exactly as before. When token is given, it is attached to this one request instead of whatever setToken last set -- a single session routinely needs a different token per request when its peer shares more than one scope with this side (e.g. several core/room memberships over one connection), and a session-global token can only ever be correct for one of them. Absent, this request carries setToken's own session-global token exactly as before. When timeoutMs is given, the returned promise resolves with `{ result: "error", code: "timeout" }` rather than hanging forever if no manage-response arrives in time -- a held-open request (a human approval, a not-yet-online peer) otherwise has no way for the caller to give up on it. Absent, this request waits exactly as before, with no time limit of its own. */
@@ -1,7 +1,10 @@
1
1
  import { deviceIdToHex } from "./device-id.mjs";
2
2
  import { negotiate } from "./handshake.mjs";
3
+ import { createAsyncQueue } from "./async-queue.mjs";
4
+ import { createPingRoundTrips } from "./ping-round-trips.mjs";
5
+ import { createTopologySnapshotTracker } from "./topology-snapshot.mjs";
6
+ import { a as isVersionGetCommand, i as OWN_VERSION, n as validateGossipExtensions, o as versionGetOutcome, r as CORE_VERSION_GOSSIP_KEY, t as TOPOLOGY_PEERS_GOSSIP_KEY } from "../gossip-extensions-Cdy5mQhY.mjs";
3
7
  import { tryDecodeFrame, wrapRelayData } from "../adapters/frame-codec.mjs";
4
- import packageJson from "../../package.json" with { type: "json" };
5
8
  //#region src/domain/relay-pairing.ts
6
9
  function createRelayPairings() {
7
10
  const established = /* @__PURE__ */ new Map();
@@ -9,82 +12,11 @@ function createRelayPairings() {
9
12
  has: (device) => established.has(deviceIdToHex(device)),
10
13
  add: (device) => {
11
14
  established.set(deviceIdToHex(device), device);
12
- }
13
- };
14
- }
15
- //#endregion
16
- //#region src/domain/ping-round-trips.ts
17
- function createPingRoundTrips() {
18
- const pending = [];
19
- function cancel(sentAt) {
20
- const index = pending.findIndex((entry) => entry.sentAt === sentAt);
21
- if (index === -1) return false;
22
- pending.splice(index, 1);
23
- return true;
24
- }
25
- return {
26
- resolveOldest(nowMs) {
27
- const entry = pending.shift();
28
- if (entry !== void 0) entry.resolve(nowMs - entry.sentAt);
29
15
  },
30
- rejectAll(reason) {
31
- for (const entry of pending.splice(0)) entry.reject(new Error(reason));
32
- },
33
- async sendAndAwait(sentAt, sendPing, timeoutMs) {
34
- const rtt = new Promise((resolve, reject) => {
35
- pending.push({
36
- sentAt,
37
- resolve,
38
- reject
39
- });
40
- });
41
- await sendPing();
42
- if (timeoutMs === void 0) return rtt;
43
- return Promise.race([rtt, new Promise((_resolve, reject) => {
44
- setTimeout(() => {
45
- if (cancel(sentAt)) reject(/* @__PURE__ */ new Error("timed out waiting for pong"));
46
- }, timeoutMs);
47
- })]);
48
- }
49
- };
50
- }
51
- //#endregion
52
- //#region src/domain/own-version.ts
53
- /** This node's own actual, currently-running wire-mesh-core version -- read once from the installed package's own package.json, never from anything gossiped or cached, so buildSelfAdvert's own self-advert extension and version.get's manage-response both always answer with the truth for this exact running process. */
54
- const OWN_VERSION = packageJson.version;
55
- /** The gossip extension key wire-mesh-core itself injects into every self-advert (mesh-session.ts's own buildSelfAdvert) to report OWN_VERSION -- reserved so a sendGossipUpdate caller can never also supply it, separately from peer-advert's own typed struct fields (RESERVED_PEER_ADVERT_KEYS in mesh-session.ts), since this is just an extension-tail key (transport.cddl), not a struct field. */
56
- const CORE_VERSION_GOSSIP_KEY = "wire-mesh/version";
57
- const VERSION_GET_VERB = "version.get";
58
- /** True when params is the core/version domain's own version-get command (version.cddl) -- a receiver answers this unconditionally, bypassing incomingManageRequests entirely, since it is deliberately ungated: exactly as sensitive as what CORE_VERSION_GOSSIP_KEY already broadcasts unconditionally via gossip. */
59
- function isVersionGetCommand(params) {
60
- return "verb" in params && params.verb === VERSION_GET_VERB;
61
- }
62
- /** The manage-ok outcome mesh-session.ts answers a version.get request with -- OWN_VERSION on manage-ok's own open `* tstr => any` tail, the same pattern exec-list's own exec-session-info result already uses. */
63
- function versionGetOutcome() {
64
- return {
65
- result: "ok",
66
- version: OWN_VERSION
16
+ list: () => [...established.values()]
67
17
  };
68
18
  }
69
19
  //#endregion
70
- //#region src/domain/gossip-extensions.ts
71
- /** peer-advert's own three typed fields -- reserved so a `sendGossipUpdate` caller can never override the session's own device-id, address list, or freshness timestamp by supplying an extension of the same name. */
72
- const RESERVED_PEER_ADVERT_KEYS = /* @__PURE__ */ new Set([
73
- "device",
74
- "addresses",
75
- "snapshot-seconds"
76
- ]);
77
- /** A gossip extension key must be domain-qualified as `<domain>/<field>` (lowercase kebab-case each side), per `spec/CONVENTIONS.md`'s gossip-extension-namespacing convention -- this is what stops two independent applications sharing one gossip tail from silently colliding on a bare name like "status". */
78
- const GOSSIP_EXTENSION_KEY_PATTERN = /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/;
79
- /** Rejects a `sendGossipUpdate` extension bag that would either shadow one of peer-advert's own mandatory fields, use the key wire-mesh-core itself reserves for its own self-reported version, or use a bare, non-domain-qualified key -- all are caller bugs that must fail loudly at the call site, not silently corrupt or ambiguously merge into the wire frame. */
80
- function validateGossipExtensions(extensions) {
81
- for (const key of Object.keys(extensions)) {
82
- if (RESERVED_PEER_ADVERT_KEYS.has(key)) throw new Error(`sendGossipUpdate extension key "${key}" collides with a mandatory peer-advert field`);
83
- if (key === "wire-mesh/version") throw new Error(`sendGossipUpdate extension key "${key}" is reserved for wire-mesh-core's own self-reported version`);
84
- if (!GOSSIP_EXTENSION_KEY_PATTERN.test(key)) throw new Error(`sendGossipUpdate extension key "${key}" must be domain-qualified as "<domain>/<field>" (e.g. "presence/status")`);
85
- }
86
- }
87
- //#endregion
88
20
  //#region src/domain/mesh-session.ts
89
21
  const MS_PER_SECOND = 1e3;
90
22
  /** How long to wait for the node's handshake before calling it unanswered. A relay-only node never sends one; that is a state to display, not an error. */
@@ -106,19 +38,21 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
106
38
  const directory = /* @__PURE__ */ new Map();
107
39
  const frameLog = [];
108
40
  let feedCancelled = false;
109
- const eventWaiters = [];
110
- const eventBacklog = [];
41
+ const eventQueue = createAsyncQueue();
111
42
  let handshakeTimer = null;
112
43
  let reconnectTimer = null;
113
44
  let attempt = 0;
114
45
  let currentToken = null;
115
46
  let nextRequestId = 0;
116
47
  const relayPairings = createRelayPairings();
48
+ const topology = createTopologySnapshotTracker({
49
+ getAuthenticatedPeer: () => connection?.peerDeviceId,
50
+ isAccepted: dial === null,
51
+ getRelayedDevices: () => relayPairings.list()
52
+ });
117
53
  const pendingManageRequests = /* @__PURE__ */ new Map();
118
- const incomingWaiters = [];
119
- const incomingBacklog = [];
120
- const revocationWaiters = [];
121
- const revocationBacklog = [];
54
+ const incomingQueue = createAsyncQueue();
55
+ const revocationQueue = createAsyncQueue();
122
56
  const pingRoundTrips = createPingRoundTrips();
123
57
  function snapshot() {
124
58
  return {
@@ -128,20 +62,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
128
62
  };
129
63
  }
130
64
  function emit() {
131
- const event = snapshot();
132
- const waiter = eventWaiters.shift();
133
- if (waiter) waiter(event);
134
- else eventBacklog.push(event);
135
- }
136
- function emitIncomingManageRequest(request) {
137
- const waiter = incomingWaiters.shift();
138
- if (waiter) waiter(request);
139
- else incomingBacklog.push(request);
140
- }
141
- function emitRevocationEntry(entry) {
142
- const waiter = revocationWaiters.shift();
143
- if (waiter) waiter(entry);
144
- else revocationBacklog.push(entry);
65
+ eventQueue.push(snapshot());
145
66
  }
146
67
  function rejectPendingManageRequests(reason) {
147
68
  for (const pending of pendingManageRequests.values()) pending.reject(new Error(reason));
@@ -211,7 +132,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
211
132
  incoming.respond(versionGetOutcome());
212
133
  return;
213
134
  }
214
- emitIncomingManageRequest(incoming);
135
+ incomingQueue.push(incoming);
215
136
  }
216
137
  function applyFrame(frame) {
217
138
  if (frame.type === "relay-data") {
@@ -241,13 +162,14 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
241
162
  device: advert.device,
242
163
  advert
243
164
  });
165
+ topology.recordAdvert(advert.device);
244
166
  onPeerAdvert?.(advert);
245
167
  }
246
168
  else if (frame.type === "relay-inbound") relayPairings.add(frame["source-device"]);
247
169
  else if (frame.type === "manage-response") applyManageResponse(frame);
248
170
  else if (frame.type === "manage-request") applyManageRequest(frame);
249
171
  else if (frame.type === "pong") pingRoundTrips.resolveOldest(clock.now());
250
- else if (frame.type === "revocation-announce") for (const entry of frame.entries) emitRevocationEntry(entry);
172
+ else if (frame.type === "revocation-announce") for (const entry of frame.entries) revocationQueue.push(entry);
251
173
  }
252
174
  /** Establishes a relay-connect pairing to targetDevice if this session isn't already paired with it -- a no-op when it already is, whether that pairing was established by this session's own prior relay-connect (initiator role) or learned from an incoming relay-inbound (target role, replying back to whoever dialed it). Pairing with a new target never tears down an existing pairing with a different one: this connection can hold several simultaneously (wire-mesh#30's own multiplexed adjacency map), so a later request back to an already-paired target must not re-send relay-connect for it. relay-connect has no ack frame: the initiator proceeds to relay-data right after sending it. */
253
175
  async function ensureRelayPairing(targetDevice) {
@@ -325,7 +247,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
325
247
  onSessionEnd?.(link);
326
248
  if (state.status === "connected") handleDisconnect("node closed the connection", address, localDomains);
327
249
  }
328
- /** Builds this side's own self-advert: this node's own directly-reachable addresses (wire-mesh#38), or none for a caller with nothing to offer (a browser client, which cannot accept inbound connections) -- either is an honest advert, not a stopgap. extensions merge onto peer-advert's own open `* tstr => any` tail -- the mechanism sendGossipUpdate uses to keep a gossiped fact (presence status, an accept/refuse policy, or any future domain's own) live over the connection's lifetime. Extensions and CORE_VERSION_GOSSIP_KEY are spread before the mandatory fields (never after) so a caller-supplied key of the same name can never shadow them on the wire -- validateGossipExtensions already rejects both collisions loudly, but the field order is kept safe in its own right rather than relying solely on the guard staying in sync. */
250
+ /** Builds this side's own self-advert: this node's own directly-reachable addresses (wire-mesh#38), or none for a caller with nothing to offer (a browser client, which cannot accept inbound connections) -- either is an honest advert, not a stopgap. extensions merge onto peer-advert's own open `* tstr => any` tail -- the mechanism sendGossipUpdate uses to keep a gossiped fact (presence status, an accept/refuse policy, or any future domain's own) live over the connection's lifetime. Extensions, CORE_VERSION_GOSSIP_KEY, and topology/peers are all spread before the mandatory fields (never after) so a caller-supplied key of the same name can never shadow them on the wire -- validateGossipExtensions already rejects every such collision loudly, but the field order is kept safe in its own right rather than relying solely on the guard staying in sync. topology/peers (wire-mesh#180) is recomputed fresh on every call, the same "always current, never cached" treatment snapshot-seconds already gets, since a session's own connection identity and relay pairings can change between one self-advert and the next. */
329
251
  function buildSelfAdvert(extensions) {
330
252
  if (extensions !== void 0) validateGossipExtensions(extensions);
331
253
  return {
@@ -333,6 +255,7 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
333
255
  peers: [{
334
256
  ...extensions,
335
257
  [CORE_VERSION_GOSSIP_KEY]: OWN_VERSION,
258
+ [TOPOLOGY_PEERS_GOSSIP_KEY]: topology.compute(),
336
259
  device: identity.deviceId,
337
260
  addresses: [...addresses],
338
261
  "snapshot-seconds": Math.floor(clock.now() / MS_PER_SECOND)
@@ -398,51 +321,9 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
398
321
  return {
399
322
  wireUpConnection,
400
323
  session: {
401
- events: { [Symbol.asyncIterator]() {
402
- return { next: async () => new Promise((resolve) => {
403
- const backlogEvent = eventBacklog.shift();
404
- if (backlogEvent) resolve({
405
- value: backlogEvent,
406
- done: false
407
- });
408
- else eventWaiters.push((event) => {
409
- resolve({
410
- value: event,
411
- done: false
412
- });
413
- });
414
- }) };
415
- } },
416
- incomingManageRequests: { [Symbol.asyncIterator]() {
417
- return { next: async () => new Promise((resolve) => {
418
- const backlogRequest = incomingBacklog.shift();
419
- if (backlogRequest) resolve({
420
- value: backlogRequest,
421
- done: false
422
- });
423
- else incomingWaiters.push((request) => {
424
- resolve({
425
- value: request,
426
- done: false
427
- });
428
- });
429
- }) };
430
- } },
431
- revocationAnnouncements: { [Symbol.asyncIterator]() {
432
- return { next: async () => new Promise((resolve) => {
433
- const backlogEntry = revocationBacklog.shift();
434
- if (backlogEntry) resolve({
435
- value: backlogEntry,
436
- done: false
437
- });
438
- else revocationWaiters.push((entry) => {
439
- resolve({
440
- value: entry,
441
- done: false
442
- });
443
- });
444
- }) };
445
- } },
324
+ events: eventQueue.stream,
325
+ incomingManageRequests: incomingQueue.stream,
326
+ revocationAnnouncements: revocationQueue.stream,
446
327
  async connect(address, localDomains) {
447
328
  if (connection !== null) throw new Error("a session connects once; create a new one to reconnect");
448
329
  attempt = 0;
@@ -523,6 +404,9 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
523
404
  await transmit(frame, false);
524
405
  emit();
525
406
  },
407
+ getTopologyPeers() {
408
+ return topology.compute();
409
+ },
526
410
  async sendDataFrame(frame) {
527
411
  requireConnectedLink();
528
412
  frameLog.push({
@@ -1,8 +1,8 @@
1
- import { D as DeviceId, Ot as RoomNotice, T as DataHaveFrame, kt as RoomNoticeClaims, p as CapabilityToken } from "../protocol-D2Ue2nd2.cjs";
1
+ import { D as DeviceId, Ot as RoomNotice, T as DataHaveFrame, kt as RoomNoticeClaims, p as CapabilityToken } from "../protocol-BnbpCk0Z.cjs";
2
2
  import { KeyValueStorage } from "../ports/storage.cjs";
3
- import { t as IdentityPort } from "../identity-pOt5KxJB.cjs";
3
+ import { t as IdentityPort } from "../identity-BRlM-Krs.cjs";
4
4
  import { t as Clock } from "../clock-DiSx-WKM.cjs";
5
- import { a as RevocationCheck } from "../tokens-BtczHfrc.cjs";
5
+ import { a as RevocationCheck } from "../tokens-BL2BzOmN.cjs";
6
6
  import { RoomNoticeVerdictReason } from "./room.cjs";
7
7
  //#region src/domain/notice-board.d.ts
8
8
  /**
@@ -1,8 +1,8 @@
1
- import { D as DeviceId, Ot as RoomNotice, T as DataHaveFrame, kt as RoomNoticeClaims, p as CapabilityToken } from "../protocol-D2Ue2nd2.mjs";
1
+ import { D as DeviceId, Ot as RoomNotice, T as DataHaveFrame, kt as RoomNoticeClaims, p as CapabilityToken } from "../protocol-BnbpCk0Z.mjs";
2
2
  import { KeyValueStorage } from "../ports/storage.mjs";
3
- import { t as IdentityPort } from "../identity-Br6e0Rx5.mjs";
3
+ import { t as IdentityPort } from "../identity-DiuayfEd.mjs";
4
4
  import { t as Clock } from "../clock-DiSx-WKM.mjs";
5
- import { a as RevocationCheck } from "../tokens-Hnq9oURj.mjs";
5
+ import { a as RevocationCheck } from "../tokens-DtjdYS_a.mjs";
6
6
  import { RoomNoticeVerdictReason } from "./room.mjs";
7
7
  //#region src/domain/notice-board.d.ts
8
8
  /**