@unagents/messenger-broker 0.9.0 → 0.11.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/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { Peer, Message, RelayedMessage } from '@unagents/messenger-protocol';
1
+ import { Peer, Message, RelayedMessage, StoredProtocol } from '@unagents/messenger-protocol';
2
2
 
3
3
  /**
4
4
  * Broker configuration. Every field can be given to `startBroker(options)`;
@@ -22,6 +22,11 @@ interface BrokerOptions {
22
22
  discoveryPort?: number;
23
23
  /** UDP bind address when pinned (`AGENT_MESSENGER_DISCOVERY_ADDR`, default `""` = all interfaces). */
24
24
  discoveryAddr?: string;
25
+ /**
26
+ * Explicit discovery send targets, `host:port` each (`AGENT_MESSENGER_DISCOVERY_TARGETS`, comma separated).
27
+ * When non-empty, probes and announces go to these instead of broadcast (tests; LAN without broadcast).
28
+ */
29
+ discoveryTargets?: string[];
25
30
  /**
26
31
  * Run as a local fallback broker (`AGENT_MESSENGER_FALLBACK=1`, set by the client when it spawns one).
27
32
  * A fallback probes for other brokers every `fallbackIntervalMs`; when it finds one it relays its
@@ -38,16 +43,16 @@ interface BrokerOptions {
38
43
  */
39
44
  mesh?: boolean;
40
45
  /**
41
- * Mesh members to sync with. Empty (default) = the `brokers` list of the client
42
- * config file (`AGENT_MESSENGER_CONFIG`, default `~/.agent-messenger/config.json`), re-read every round.
46
+ * Test-only: a fixed list of mesh members that replaces discovery. Empty (default) = members are
47
+ * found by UDP discovery (announce / probe reply, token-checked). Not documented for users.
43
48
  */
44
49
  meshBrokers?: string[];
50
+ /** Mesh announce interval in ms (`AGENT_MESSENGER_MESH_ANNOUNCE_MS`, default 10000). */
51
+ meshAnnounceMs?: number;
45
52
  /** Mesh sync interval in ms (`AGENT_MESSENGER_MESH_INTERVAL_MS`, default 5000). */
46
53
  meshIntervalMs?: number;
47
54
  /** Replicated peers disappear this long after their origin stopped syncing (`AGENT_MESSENGER_MESH_PEER_TTL_MS`, default 300000). */
48
55
  meshPeerTtlMs?: number;
49
- /** Client config file read for the mesh member list (`AGENT_MESSENGER_CONFIG`). */
50
- configFile?: string;
51
56
  }
52
57
  type BrokerConfig = Required<Omit<BrokerOptions, "onSupersede">> & Pick<BrokerOptions, "onSupersede">;
53
58
  /** Marks a broker as a local fallback. Broker-private (not in the protocol ENV table, which mirrors legacy). */
@@ -78,6 +83,8 @@ interface BrokerStateFile {
78
83
  nextMsgId: number;
79
84
  /** Relayed messages waiting for their recipient to register (absent in files written before the fallback relay). */
80
85
  pending?: PendingRelay[];
86
+ /** Protocol definitions (absent in files written before the protocol registry, or when none is held). */
87
+ protocols?: StoredProtocol[];
81
88
  }
82
89
  /** A message relayed from a fallback broker whose recipient has not registered here yet. */
83
90
  interface PendingRelay extends RelayedMessage {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Peer, Message, RelayedMessage } from '@unagents/messenger-protocol';
1
+ import { Peer, Message, RelayedMessage, StoredProtocol } from '@unagents/messenger-protocol';
2
2
 
3
3
  /**
4
4
  * Broker configuration. Every field can be given to `startBroker(options)`;
@@ -22,6 +22,11 @@ interface BrokerOptions {
22
22
  discoveryPort?: number;
23
23
  /** UDP bind address when pinned (`AGENT_MESSENGER_DISCOVERY_ADDR`, default `""` = all interfaces). */
24
24
  discoveryAddr?: string;
25
+ /**
26
+ * Explicit discovery send targets, `host:port` each (`AGENT_MESSENGER_DISCOVERY_TARGETS`, comma separated).
27
+ * When non-empty, probes and announces go to these instead of broadcast (tests; LAN without broadcast).
28
+ */
29
+ discoveryTargets?: string[];
25
30
  /**
26
31
  * Run as a local fallback broker (`AGENT_MESSENGER_FALLBACK=1`, set by the client when it spawns one).
27
32
  * A fallback probes for other brokers every `fallbackIntervalMs`; when it finds one it relays its
@@ -38,16 +43,16 @@ interface BrokerOptions {
38
43
  */
39
44
  mesh?: boolean;
40
45
  /**
41
- * Mesh members to sync with. Empty (default) = the `brokers` list of the client
42
- * config file (`AGENT_MESSENGER_CONFIG`, default `~/.agent-messenger/config.json`), re-read every round.
46
+ * Test-only: a fixed list of mesh members that replaces discovery. Empty (default) = members are
47
+ * found by UDP discovery (announce / probe reply, token-checked). Not documented for users.
43
48
  */
44
49
  meshBrokers?: string[];
50
+ /** Mesh announce interval in ms (`AGENT_MESSENGER_MESH_ANNOUNCE_MS`, default 10000). */
51
+ meshAnnounceMs?: number;
45
52
  /** Mesh sync interval in ms (`AGENT_MESSENGER_MESH_INTERVAL_MS`, default 5000). */
46
53
  meshIntervalMs?: number;
47
54
  /** Replicated peers disappear this long after their origin stopped syncing (`AGENT_MESSENGER_MESH_PEER_TTL_MS`, default 300000). */
48
55
  meshPeerTtlMs?: number;
49
- /** Client config file read for the mesh member list (`AGENT_MESSENGER_CONFIG`). */
50
- configFile?: string;
51
56
  }
52
57
  type BrokerConfig = Required<Omit<BrokerOptions, "onSupersede">> & Pick<BrokerOptions, "onSupersede">;
53
58
  /** Marks a broker as a local fallback. Broker-private (not in the protocol ENV table, which mirrors legacy). */
@@ -78,6 +83,8 @@ interface BrokerStateFile {
78
83
  nextMsgId: number;
79
84
  /** Relayed messages waiting for their recipient to register (absent in files written before the fallback relay). */
80
85
  pending?: PendingRelay[];
86
+ /** Protocol definitions (absent in files written before the protocol registry, or when none is held). */
87
+ protocols?: StoredProtocol[];
81
88
  }
82
89
  /** A message relayed from a fallback broker whose recipient has not registered here yet. */
83
90
  interface PendingRelay extends RelayedMessage {
package/dist/index.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  import 'listhen';
2
2
  import 'h3';
3
3
  import '@unagents/messenger-protocol';
4
- export { D as DEFAULT_STATE_FILE, F as FALLBACK_ENV, a as FALLBACK_INTERVAL_ENV, M as MESH_ENV, S as STATE_DIR, V as VERSION, c as configFromEnv, r as resolveConfig, s as startBroker } from './shared/messenger-broker.CY2SPlVR.mjs';
4
+ export { D as DEFAULT_STATE_FILE, F as FALLBACK_ENV, a as FALLBACK_INTERVAL_ENV, M as MESH_ENV, S as STATE_DIR, V as VERSION, c as configFromEnv, r as resolveConfig, s as startBroker } from './shared/messenger-broker.Co46RRtX.mjs';
5
5
  import 'node:os';
6
6
  import 'node:path';
7
7
  import 'defu';
8
8
  import 'node:fs';
9
9
  import 'consola';
10
+ import 'node:crypto';
10
11
  import 'node:http';
11
12
  import 'node:dgram';
package/dist/main.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { s as startBroker, l as log } from './shared/messenger-broker.CY2SPlVR.mjs';
1
+ import { s as startBroker, l as log } from './shared/messenger-broker.Co46RRtX.mjs';
2
2
  import 'listhen';
3
3
  import 'h3';
4
4
  import '@unagents/messenger-protocol';
@@ -7,6 +7,7 @@ import 'node:path';
7
7
  import 'defu';
8
8
  import 'node:fs';
9
9
  import 'consola';
10
+ import 'node:crypto';
10
11
  import 'node:http';
11
12
  import 'node:dgram';
12
13
 
@@ -1,11 +1,12 @@
1
1
  import { listen } from 'listhen';
2
2
  import { createRouter, defineEventHandler, createApp, send, setResponseStatus, getRequestHeader, readRawBody, toNodeListener } from 'h3';
3
- import { DEFAULT_BIND, ENV, DEFAULT_DISCOVERY_PORT, DEFAULT_HTTP_PORT, HEARTBEAT_TIMEOUT_MS, MESH_SYNC_ROUTE, HEALTH_ROUTE, BROKER_POST_ROUTES, RELAY_ROUTE, DISCOVERY_KEY, DISCOVERY_PROBE_VALUE, DISCOVERY_REPLY_VALUE } from '@unagents/messenger-protocol';
3
+ import { DEFAULT_BIND, MESH_ANNOUNCE_ENV, DISCOVERY_TARGETS_ENV, ENV, DEFAULT_DISCOVERY_PORT, DEFAULT_HTTP_PORT, PROTOCOL_NAME_PATTERN, PROTOCOL_META_MAX_LENGTH, PROTOCOL_MANIFEST_MAX_BYTES, PROTOCOL_DOC_MAX_BYTES, PROTOCOL_TOTAL_MAX_BYTES, canonicalJSON, HEARTBEAT_TIMEOUT_MS, PROTOCOL_MAX_STORED, MESH_SYNC_ROUTE, DISCOVERY_KEY, DISCOVERY_ANNOUNCE_VALUE, MESH_ANNOUNCE_MAX_SKEW_MS, DISCOVERY_REPLY_VALUE, meshMacEqual, meshMac, HEALTH_ROUTE, BROKER_POST_ROUTES, PROTOCOL_ROUTES, RELAY_ROUTE, DISCOVERY_PROBE_VALUE } from '@unagents/messenger-protocol';
4
4
  import { homedir, hostname, networkInterfaces } from 'node:os';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { defu } from 'defu';
7
7
  import { readFileSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
8
8
  import { createConsola } from 'consola';
9
+ import { createHash, randomBytes } from 'node:crypto';
9
10
  import { request } from 'node:http';
10
11
  import { createSocket } from 'node:dgram';
11
12
 
@@ -29,13 +30,14 @@ function configFromEnv(env = process.env) {
29
30
  discovery: (env[ENV.DISCOVERY] ?? "on").toLowerCase() !== "off",
30
31
  discoveryPort: parseInt(env[ENV.DISCOVERY_PORT] ?? String(DEFAULT_DISCOVERY_PORT), 10),
31
32
  discoveryAddr: env[ENV.DISCOVERY_ADDR] ?? "",
33
+ discoveryTargets: (env[DISCOVERY_TARGETS_ENV] ?? "").split(",").map((s) => s.trim()).filter(Boolean),
32
34
  fallback: ["1", "true", "on", "yes"].includes((env[FALLBACK_ENV] ?? "").toLowerCase()),
33
35
  fallbackIntervalMs: parseInt(env[FALLBACK_INTERVAL_ENV] ?? String(DEFAULT_FALLBACK_INTERVAL_MS), 10),
34
36
  mesh: (env[MESH_ENV.MESH] ?? "off").toLowerCase() === "on",
35
37
  meshBrokers: [],
38
+ meshAnnounceMs: parseInt(env[MESH_ANNOUNCE_ENV] ?? "10000", 10),
36
39
  meshIntervalMs: parseInt(env[MESH_ENV.INTERVAL_MS] ?? "5000", 10),
37
- meshPeerTtlMs: parseInt(env[MESH_ENV.PEER_TTL_MS] ?? "300000", 10),
38
- configFile: env[ENV.CONFIG] ?? join(STATE_DIR, "config.json")
40
+ meshPeerTtlMs: parseInt(env[MESH_ENV.PEER_TTL_MS] ?? "300000", 10)
39
41
  };
40
42
  }
41
43
  function resolveConfig(options = {}) {
@@ -54,6 +56,8 @@ class BrokerState {
54
56
  messages = [];
55
57
  nextMsgId = 1;
56
58
  pending = [];
59
+ /** Protocol definitions by `"name@version"`. Kept while at least one alive peer declares them (see `BrokerHandlers.gcProtocols`). */
60
+ protocols = /* @__PURE__ */ new Map();
57
61
  /** Mesh: when each origin broker last synced with us (ms epoch, memory only). Replicated peers live as long as this is fresh. */
58
62
  originSeen = /* @__PURE__ */ new Map();
59
63
  saveTimer = null;
@@ -68,7 +72,12 @@ class BrokerState {
68
72
  this.messages = raw.messages ?? [];
69
73
  this.nextMsgId = raw.nextMsgId ?? 1;
70
74
  this.pending = raw.pending ?? [];
71
- log.info(`Loaded state from ${this.file} (${this.peers.size} peer(s), ${this.messages.length} message(s))`);
75
+ for (const sp of raw.protocols ?? []) {
76
+ if (sp && typeof sp.name === "string" && typeof sp.version === "string") this.protocols.set(`${sp.name}@${sp.version}`, sp);
77
+ }
78
+ log.info(
79
+ `Loaded state from ${this.file} (${this.peers.size} peer(s), ${this.messages.length} message(s)${this.protocols.size ? `, ${this.protocols.size} protocol(s)` : ""})`
80
+ );
72
81
  } catch {
73
82
  }
74
83
  }
@@ -93,7 +102,8 @@ class BrokerState {
93
102
  peers: [...this.peers.values()],
94
103
  messages: this.messages,
95
104
  nextMsgId: this.nextMsgId,
96
- ...this.pending.length ? { pending: this.pending } : {}
105
+ ...this.pending.length ? { pending: this.pending } : {},
106
+ ...this.protocols.size ? { protocols: [...this.protocols.values()] } : {}
97
107
  };
98
108
  writeFileSync(tmp, JSON.stringify(snapshot, null, 1));
99
109
  renameSync(tmp, this.file);
@@ -107,6 +117,112 @@ class BrokerState {
107
117
  }
108
118
  }
109
119
 
120
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
121
+ const HASH_PATTERN = /^[0-9a-f]{64}$/;
122
+ const MAX_FIELD_LENGTH = 1024;
123
+ function protocolHash(manifest, doc) {
124
+ return createHash("sha256").update(canonicalJSON({ manifest, doc })).digest("hex");
125
+ }
126
+ function isPlainObject(v) {
127
+ return typeof v === "object" && v !== null && !Array.isArray(v);
128
+ }
129
+ function checkDefinition(name, version, def) {
130
+ const bad = (error) => ({ ok: false, code: "INVALID_DEFINITION", error });
131
+ if (typeof name !== "string" || !PROTOCOL_NAME_PATTERN.test(name)) {
132
+ return bad(`invalid protocol name (expected ${String(PROTOCOL_NAME_PATTERN)})`);
133
+ }
134
+ if (typeof version !== "string" || version.length > MAX_FIELD_LENGTH || !VERSION_PATTERN.test(version)) {
135
+ return bad("invalid version (expected semver, e.g. 1.2.0 or 0.0.0+abc1234)");
136
+ }
137
+ if (!isPlainObject(def)) return bad("missing definition");
138
+ const { manifest, doc, source, ref } = def;
139
+ if (!isPlainObject(manifest)) return bad("definition.manifest must be an object");
140
+ if (typeof doc !== "string") return bad("definition.doc must be a string");
141
+ if (typeof source !== "string" || !source || source.length > MAX_FIELD_LENGTH) return bad("definition.source must be a non-empty string");
142
+ if (typeof ref !== "string" || !ref || ref.length > MAX_FIELD_LENGTH) return bad("definition.ref must be a non-empty string");
143
+ if (manifest.name !== name) return bad(`protocol.json "name" must equal "${name}"`);
144
+ if (manifest.description !== void 0 && typeof manifest.description !== "string") return bad('protocol.json "description" must be a string');
145
+ if (manifest.messages !== void 0) {
146
+ if (!isPlainObject(manifest.messages)) return bad('protocol.json "messages" must be an object');
147
+ for (const [k, schema] of Object.entries(manifest.messages)) {
148
+ if (!isPlainObject(schema)) return bad(`protocol.json messages.${k} must be an object (JSON Schema)`);
149
+ }
150
+ }
151
+ const manifestBytes = Buffer.byteLength(JSON.stringify(manifest));
152
+ const docBytes = Buffer.byteLength(doc);
153
+ if (manifestBytes > PROTOCOL_MANIFEST_MAX_BYTES) {
154
+ return { ok: false, code: "TOO_LARGE", error: `protocol.json is ${manifestBytes} bytes (limit ${PROTOCOL_MANIFEST_MAX_BYTES})` };
155
+ }
156
+ if (docBytes > PROTOCOL_DOC_MAX_BYTES) {
157
+ return { ok: false, code: "TOO_LARGE", error: `PROTOCOL.md is ${docBytes} bytes (limit ${PROTOCOL_DOC_MAX_BYTES})` };
158
+ }
159
+ if (manifestBytes + docBytes > PROTOCOL_TOTAL_MAX_BYTES) {
160
+ return { ok: false, code: "TOO_LARGE", error: `definition is ${manifestBytes + docBytes} bytes (limit ${PROTOCOL_TOTAL_MAX_BYTES})` };
161
+ }
162
+ return { ok: true };
163
+ }
164
+ function sanitizeDecls(list) {
165
+ if (!Array.isArray(list)) return [];
166
+ const out = [];
167
+ for (const d of list) {
168
+ if (!isPlainObject(d)) continue;
169
+ const { name, version, hash } = d;
170
+ if (typeof name !== "string" || !PROTOCOL_NAME_PATTERN.test(name)) continue;
171
+ if (typeof version !== "string" || version.length > MAX_FIELD_LENGTH || !VERSION_PATTERN.test(version)) continue;
172
+ if (typeof hash !== "string" || !HASH_PATTERN.test(hash)) continue;
173
+ if (out.some((o) => o.name === name)) continue;
174
+ out.push({ name, version, hash });
175
+ }
176
+ return out;
177
+ }
178
+ function checkMeta(meta) {
179
+ if (!isPlainObject(meta)) return null;
180
+ const { protocol, version } = meta;
181
+ if (typeof protocol !== "string" || typeof version !== "string") return null;
182
+ if (protocol.length > PROTOCOL_META_MAX_LENGTH || version.length > PROTOCOL_META_MAX_LENGTH) return null;
183
+ return { protocol, version };
184
+ }
185
+ function isTagless(v) {
186
+ return v.startsWith("0.0.0+");
187
+ }
188
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/;
189
+ function compareVersions(a, b) {
190
+ const ta = isTagless(a);
191
+ const tb = isTagless(b);
192
+ if (ta || tb) return ta && tb ? 0 : ta ? -1 : 1;
193
+ const ma = SEMVER.exec(a);
194
+ const mb = SEMVER.exec(b);
195
+ if (!ma || !mb) return a < b ? -1 : a > b ? 1 : 0;
196
+ for (let i = 1; i <= 3; i++) {
197
+ const x = BigInt(ma[i]);
198
+ const y = BigInt(mb[i]);
199
+ if (x !== y) return x < y ? -1 : 1;
200
+ }
201
+ const pa = ma[4];
202
+ const pb = mb[4];
203
+ if (pa === void 0 && pb === void 0) return 0;
204
+ if (pa === void 0) return 1;
205
+ if (pb === void 0) return -1;
206
+ const ia = pa.split(".");
207
+ const ib = pb.split(".");
208
+ for (let i = 0; i < Math.min(ia.length, ib.length); i++) {
209
+ const x = ia[i];
210
+ const y = ib[i];
211
+ const nx = /^\d+$/.test(x);
212
+ const ny = /^\d+$/.test(y);
213
+ if (nx && ny) {
214
+ const dx = BigInt(x);
215
+ const dy = BigInt(y);
216
+ if (dx !== dy) return dx < dy ? -1 : 1;
217
+ } else if (nx !== ny) {
218
+ return nx ? -1 : 1;
219
+ } else if (x !== y) {
220
+ return x < y ? -1 : 1;
221
+ }
222
+ }
223
+ return ia.length === ib.length ? 0 : ia.length < ib.length ? -1 : 1;
224
+ }
225
+
110
226
  const PENDING_RELAY_TTL_MS = 60 * 60 * 1e3;
111
227
  const DEFAULT_MESH_PEER_TTL_MS = 3e5;
112
228
  const MESH_KEY_MEMORY = 2e3;
@@ -116,6 +232,8 @@ class BrokerHandlers {
116
232
  this.opts = opts;
117
233
  }
118
234
  recentMeshKeys = /* @__PURE__ */ new Set();
235
+ /** Mesh protocol conflicts already logged (`name@version:hash`), so a standing conflict warns once, not every round. */
236
+ warnedProtocolConflicts = /* @__PURE__ */ new Set();
119
237
  // --- peer bookkeeping ---
120
238
  /** Cutoff `last_seen` (ISO) below which a peer counts as stale. */
121
239
  static cutoff() {
@@ -158,6 +276,56 @@ class BrokerHandlers {
158
276
  this.state.pending = kept;
159
277
  this.state.scheduleSave();
160
278
  }
279
+ this.gcProtocols();
280
+ }
281
+ // --- protocol store ---
282
+ /** Alive peers (replicas included) that declare `name@version`. */
283
+ serversOf(name, version) {
284
+ const cutoff = BrokerHandlers.cutoff();
285
+ return [...this.state.peers.values()].filter(
286
+ (p) => this.isAlive(p, cutoff) && p.protocols?.some((d) => d.name === name && d.version === version)
287
+ );
288
+ }
289
+ /**
290
+ * Drop stored definitions that no alive peer (replicas included) declares by `(name, version)` any more.
291
+ * Runs after `cleanStalePeers`, `register`, `unregister`, `/set-protocol`, `/unset-protocol`, `meshApplyPeers`
292
+ * and before `/get-protocol` answers.
293
+ */
294
+ gcProtocols() {
295
+ if (this.state.protocols.size === 0) return;
296
+ const cutoff = BrokerHandlers.cutoff();
297
+ const live = /* @__PURE__ */ new Set();
298
+ for (const p of this.state.peers.values()) {
299
+ if (!this.isAlive(p, cutoff)) continue;
300
+ for (const d of p.protocols ?? []) live.add(`${d.name}@${d.version}`);
301
+ }
302
+ let removed = 0;
303
+ for (const key of [...this.state.protocols.keys()]) {
304
+ if (!live.has(key)) {
305
+ this.state.protocols.delete(key);
306
+ removed++;
307
+ }
308
+ }
309
+ if (removed) {
310
+ log.info(`Dropped ${removed} protocol definition(s) without a declaring peer`);
311
+ this.state.scheduleSave();
312
+ }
313
+ }
314
+ /**
315
+ * Record `decls` on `peer` (hash only). A name the peer already declares is left alone (only `/set-protocol`
316
+ * changes a declaration, so a heartbeat that raced a change cannot undo it), and so is a declaration whose hash
317
+ * differs from the definition stored for the same `(name, version)`.
318
+ */
319
+ mergeDecls(peer, decls) {
320
+ let changed = false;
321
+ for (const d of decls) {
322
+ if (peer.protocols?.some((x) => x.name === d.name)) continue;
323
+ const stored = this.state.protocols.get(`${d.name}@${d.version}`);
324
+ if (stored && stored.hash !== d.hash) continue;
325
+ peer.protocols = [...peer.protocols ?? [], d];
326
+ changed = true;
327
+ }
328
+ return changed;
161
329
  }
162
330
  /** A peer that registered here (not a mesh replica). Only those can heartbeat / rename. */
163
331
  localPeer(id) {
@@ -206,7 +374,10 @@ class BrokerHandlers {
206
374
  registered_at: now,
207
375
  last_seen: now
208
376
  });
209
- this.deliverPending(this.state.peers.get(id));
377
+ const peer = this.state.peers.get(id);
378
+ if (body.protocols !== void 0) this.mergeDecls(peer, sanitizeDecls(body.protocols));
379
+ this.deliverPending(peer);
380
+ this.gcProtocols();
210
381
  this.state.scheduleSave();
211
382
  return { id };
212
383
  }
@@ -214,8 +385,10 @@ class BrokerHandlers {
214
385
  const p = this.localPeer(body.id);
215
386
  if (!p) return { ok: false, error: "unknown peer (re-register)" };
216
387
  p.last_seen = (/* @__PURE__ */ new Date()).toISOString();
388
+ if (body.protocols !== void 0) this.mergeDecls(p, sanitizeDecls(body.protocols));
217
389
  this.state.scheduleSave();
218
- return { ok: true };
390
+ const resend = (p.protocols ?? []).filter((d) => !this.state.protocols.has(`${d.name}@${d.version}`)).map((d) => `${d.name}@${d.version}`);
391
+ return resend.length ? { ok: true, resend } : { ok: true };
219
392
  }
220
393
  setSummary(body) {
221
394
  const p = this.localPeer(body.id);
@@ -247,6 +420,10 @@ class BrokerHandlers {
247
420
  break;
248
421
  }
249
422
  if (body.exclude_id) list = list.filter((p) => p.id !== body.exclude_id);
423
+ if (body.protocol) {
424
+ const { protocol, version } = body;
425
+ list = list.filter((p) => p.protocols?.some((d) => d.name === protocol && (!version || d.version === version)));
426
+ }
250
427
  return list.map(({ origin: _origin, ...p }) => p);
251
428
  }
252
429
  sendMessage(body) {
@@ -255,6 +432,12 @@ class BrokerHandlers {
255
432
  const { peer } = resolved;
256
433
  const mode = body.mode ?? "pull";
257
434
  if (mode !== "pull" && mode !== "push" && mode !== "ping") return { ok: false, error: `invalid mode: ${String(mode)}` };
435
+ let meta;
436
+ if (body.meta !== void 0) {
437
+ const checked = checkMeta(body.meta);
438
+ if (!checked) return { ok: false, error: "invalid meta: expected {protocol: string, version: string}, each at most 128 characters" };
439
+ meta = checked;
440
+ }
258
441
  const msg = {
259
442
  id: this.state.nextMsgId++,
260
443
  from_id: body.from_id ?? "cli",
@@ -264,7 +447,8 @@ class BrokerHandlers {
264
447
  mode,
265
448
  sent_at: (/* @__PURE__ */ new Date()).toISOString(),
266
449
  delivered: 0,
267
- acknowledged: 0
450
+ acknowledged: 0,
451
+ ...meta ? { meta } : {}
268
452
  };
269
453
  this.state.messages.push(msg);
270
454
  this.state.scheduleSave();
@@ -293,12 +477,99 @@ class BrokerHandlers {
293
477
  }
294
478
  unregister(body) {
295
479
  this.removePeer(body.id);
480
+ this.gcProtocols();
296
481
  this.state.scheduleSave();
297
482
  return { ok: true };
298
483
  }
484
+ // --- protocol registry ---
485
+ protocolError(code, error, extra = {}) {
486
+ return { ok: false, error, code, ...extra };
487
+ }
488
+ /**
489
+ * `/set-protocol`. Order: unknown peer, definition shape / size (INVALID_DEFINITION, TOO_LARGE), hash
490
+ * re-computation (HASH_MISMATCH), different hash for the same `(name, version)` (PROTOCOL_CONFLICT), held
491
+ * definitions limit (LIMIT). The same hash again is a no-op success, so a client may re-send at will.
492
+ */
493
+ setProtocol(body) {
494
+ const peer = this.localPeer(body.id);
495
+ if (!peer) return { ok: false, error: "unknown peer" };
496
+ const checked = checkDefinition(body.name, body.version, body.definition);
497
+ if (!checked.ok) return this.protocolError(checked.code, checked.error);
498
+ const { name, version, definition } = body;
499
+ const hash = protocolHash(definition.manifest, definition.doc);
500
+ if (body.hash !== hash) {
501
+ return this.protocolError("HASH_MISMATCH", `hash mismatch for ${name}@${version}: sent ${String(body.hash)}, computed ${hash}`);
502
+ }
503
+ this.gcProtocols();
504
+ const key = `${name}@${version}`;
505
+ const existing = this.state.protocols.get(key);
506
+ if (existing && existing.hash !== hash) {
507
+ const servers = this.serversOf(name, version).map((p) => `${p.name || p.id}@${p.hostname}`);
508
+ return this.protocolError(
509
+ "PROTOCOL_CONFLICT",
510
+ `${key} is already registered with a different definition (hash ${existing.hash}, held by ${servers.join(", ") || "no server"}); every server of it must unset_protocol first`,
511
+ { existing_hash: existing.hash, servers }
512
+ );
513
+ }
514
+ if (!existing) {
515
+ if (this.state.protocols.size >= PROTOCOL_MAX_STORED) {
516
+ return this.protocolError("LIMIT", `this broker already holds ${PROTOCOL_MAX_STORED} protocol definitions (limit)`);
517
+ }
518
+ this.state.protocols.set(key, {
519
+ name,
520
+ version,
521
+ hash,
522
+ definition: { manifest: definition.manifest, doc: definition.doc, source: definition.source, ref: definition.ref },
523
+ registered_at: (/* @__PURE__ */ new Date()).toISOString()
524
+ });
525
+ }
526
+ peer.protocols = [...(peer.protocols ?? []).filter((d) => d.name !== name), { name, version, hash }];
527
+ this.gcProtocols();
528
+ this.state.scheduleSave();
529
+ return { ok: true };
530
+ }
531
+ /** `/unset-protocol`: drop one declaration by `name`, or all of the peer's. Succeeds with `removed: 0` when there was none. */
532
+ unsetProtocol(body) {
533
+ const peer = this.localPeer(body.id);
534
+ if (!peer) return { ok: false, error: "unknown peer" };
535
+ if (body.name !== void 0 && typeof body.name !== "string") return { ok: false, error: "invalid name" };
536
+ const before = peer.protocols ?? [];
537
+ const kept = body.name === void 0 ? [] : before.filter((d) => d.name !== body.name);
538
+ const removed = before.length - kept.length;
539
+ if (kept.length) peer.protocols = kept;
540
+ else delete peer.protocols;
541
+ if (removed) {
542
+ this.gcProtocols();
543
+ this.state.scheduleSave();
544
+ }
545
+ return { ok: true, removed };
546
+ }
547
+ /** `/get-protocol`: the stored definition of `name` at `version`, or at the highest held version. No round trip to any server. */
548
+ getProtocol(body) {
549
+ if (typeof body.name !== "string" || !body.name) return this.protocolError("PROTOCOL_NOT_FOUND", "protocol name is required", { versions: [] });
550
+ this.gcProtocols();
551
+ const held = [...this.state.protocols.values()].filter((sp) => sp.name === body.name);
552
+ held.sort((a, b) => compareVersions(b.version, a.version) || (a.registered_at < b.registered_at ? 1 : -1));
553
+ const versions = held.map((sp) => sp.version);
554
+ const found = body.version === void 0 ? held[0] : held.find((sp) => sp.version === body.version);
555
+ if (!found) {
556
+ const what = body.version === void 0 ? `"${body.name}"` : `"${body.name}" version "${body.version}"`;
557
+ return this.protocolError("PROTOCOL_NOT_FOUND", `Protocol ${what} not found (registered versions: ${versions.join(", ") || "none"})`, { versions });
558
+ }
559
+ return { ok: true, protocol: found, servers: this.serversOf(found.name, found.version).length };
560
+ }
561
+ /** Route table of the protocol registry in `PROTOCOL_ROUTES` order. */
562
+ protocolRoutes() {
563
+ return {
564
+ "/set-protocol": (b) => this.setProtocol(b),
565
+ "/unset-protocol": (b) => this.unsetProtocol(b),
566
+ "/get-protocol": (b) => this.getProtocol(b)
567
+ };
568
+ }
299
569
  // --- relay from a fallback broker ---
300
570
  /** Queue a message for `peerId` exactly like `sendMessage` does. */
301
571
  enqueue(peerId, m) {
572
+ const meta = m.meta === void 0 ? null : checkMeta(m.meta);
302
573
  this.state.messages.push({
303
574
  id: this.state.nextMsgId++,
304
575
  from_id: m.from_id,
@@ -308,7 +579,8 @@ class BrokerHandlers {
308
579
  mode: m.mode ?? "pull",
309
580
  sent_at: m.sent_at,
310
581
  delivered: 0,
311
- acknowledged: 0
582
+ acknowledged: 0,
583
+ ...meta ? { meta } : {}
312
584
  });
313
585
  }
314
586
  /** Give `peer` the relayed messages that were waiting for its name. */
@@ -384,8 +656,64 @@ class BrokerHandlers {
384
656
  for (const p of [...this.state.peers.values()]) {
385
657
  if (p.origin === origin && !incoming.has(p.id)) this.removePeer(p.id);
386
658
  }
659
+ this.gcProtocols();
387
660
  this.state.scheduleSave();
388
661
  }
662
+ /** Definitions declared by this broker's direct, alive peers (what a mesh sync sends). */
663
+ meshLocalProtocols() {
664
+ const out = /* @__PURE__ */ new Map();
665
+ for (const p of this.meshLocalPeers()) {
666
+ for (const d of p.protocols ?? []) {
667
+ const key = `${d.name}@${d.version}`;
668
+ const sp = this.state.protocols.get(key);
669
+ if (sp && sp.hash === d.hash) out.set(key, sp);
670
+ }
671
+ }
672
+ return [...out.values()];
673
+ }
674
+ /**
675
+ * Take over definitions another broker's direct peers declare. Only a definition that a peer we now hold
676
+ * declares with the same hash is stored (the rest would be collected at once). A `(name, version)` we already
677
+ * hold with another hash stays as it is (ours wins) and the received one is dropped with a warning.
678
+ */
679
+ meshApplyProtocols(from, protocols) {
680
+ let added = false;
681
+ for (const sp of protocols) {
682
+ const checked = checkDefinition(sp?.name, sp?.version, sp?.definition);
683
+ if (!checked.ok) {
684
+ log.warn(`mesh: dropped protocol from ${from}: ${checked.error}`);
685
+ continue;
686
+ }
687
+ const hash = protocolHash(sp.definition.manifest, sp.definition.doc);
688
+ if (hash !== sp.hash) {
689
+ log.warn(`mesh: dropped protocol ${sp.name}@${sp.version} from ${from}: hash mismatch`);
690
+ continue;
691
+ }
692
+ const key = `${sp.name}@${sp.version}`;
693
+ const mine = this.state.protocols.get(key);
694
+ if (mine) {
695
+ if (mine.hash !== hash && !this.warnedProtocolConflicts.has(`${key}:${hash}`)) {
696
+ this.warnedProtocolConflicts.add(`${key}:${hash}`);
697
+ log.warn(`mesh: protocol ${key} from ${from} differs from the one held here (${hash} vs ${mine.hash}); keeping ours`);
698
+ }
699
+ continue;
700
+ }
701
+ if (this.state.protocols.size >= PROTOCOL_MAX_STORED) {
702
+ log.warn(`mesh: dropped protocol ${key} from ${from}: ${PROTOCOL_MAX_STORED} definitions held (limit)`);
703
+ continue;
704
+ }
705
+ if (!this.serversOf(sp.name, sp.version).some((p) => p.protocols?.some((d) => d.name === sp.name && d.version === sp.version && d.hash === hash))) continue;
706
+ this.state.protocols.set(key, {
707
+ name: sp.name,
708
+ version: sp.version,
709
+ hash,
710
+ definition: { manifest: sp.definition.manifest, doc: sp.definition.doc, source: sp.definition.source, ref: sp.definition.ref },
711
+ registered_at: typeof sp.registered_at === "string" ? sp.registered_at : (/* @__PURE__ */ new Date()).toISOString()
712
+ });
713
+ added = true;
714
+ }
715
+ if (added) this.state.scheduleSave();
716
+ }
389
717
  /** Take over messages forwarded by broker `from`; returns the sender-side ids to drop (duplicates count as taken). */
390
718
  meshAccept(from, messages) {
391
719
  const accepted = [];
@@ -409,8 +737,10 @@ class BrokerHandlers {
409
737
  if (!body.from) throw new Error("mesh sync: missing from");
410
738
  if (body.from === this.opts.id) return { id: body.from, peers: [], accepted: [] };
411
739
  this.meshApplyPeers(body.from, body.peers ?? []);
740
+ this.meshApplyProtocols(body.from, body.protocols ?? []);
412
741
  const accepted = this.meshAccept(body.from, body.messages ?? []);
413
- return { id: this.opts.id ?? "", peers: this.meshLocalPeers(), accepted };
742
+ const protocols = this.meshLocalProtocols();
743
+ return { id: this.opts.id ?? "", peers: this.meshLocalPeers(), accepted, ...protocols.length ? { protocols } : {} };
414
744
  }
415
745
  /** Route table in `BROKER_POST_ROUTES` order. */
416
746
  routes() {
@@ -451,6 +781,15 @@ class Mesh {
451
781
  if (this.timer) clearInterval(this.timer);
452
782
  this.timer = null;
453
783
  }
784
+ /** True while the last sync with `url` succeeded. */
785
+ linkUp(url) {
786
+ return this.links.get(url) === "up";
787
+ }
788
+ /** Forget everything learned about `url` (its broker left or moved). */
789
+ forget(url) {
790
+ this.links.delete(url);
791
+ this.origins.delete(url);
792
+ }
454
793
  /** Sync soon (coalesced). */
455
794
  kick() {
456
795
  if (this.running) this.again = true;
@@ -485,10 +824,12 @@ class Mesh {
485
824
  if (this.links.get(url) === "self" || this.stopped) return;
486
825
  const { handlers, id } = this.opts;
487
826
  const origin = this.origins.get(url);
827
+ const protocols = handlers.meshLocalProtocols();
488
828
  const req = {
489
829
  from: id,
490
830
  peers: handlers.meshLocalPeers(),
491
- messages: origin ? handlers.meshOutbox(origin) : []
831
+ messages: origin ? handlers.meshOutbox(origin) : [],
832
+ ...protocols.length ? { protocols } : {}
492
833
  };
493
834
  let res;
494
835
  try {
@@ -506,29 +847,12 @@ class Mesh {
506
847
  const learned = this.origins.get(url) !== res.id;
507
848
  this.origins.set(url, res.id);
508
849
  handlers.meshApplyPeers(res.id, res.peers ?? []);
850
+ handlers.meshApplyProtocols(res.id, res.protocols ?? []);
509
851
  handlers.meshDrop(res.accepted ?? []);
510
852
  if (learned && handlers.meshOutbox(res.id).length) this.again = true;
511
853
  this.setLink(url, "up");
512
854
  }
513
855
  }
514
- function readMeshBrokers(configFile) {
515
- try {
516
- const parsed = JSON.parse(readFileSync(configFile, "utf8"));
517
- const list = Array.isArray(parsed.brokers) ? parsed.brokers : parsed.broker ? [parsed.broker] : [];
518
- const out = [];
519
- for (const s of list) {
520
- if (typeof s !== "string" || !s.trim()) continue;
521
- try {
522
- out.push(normalizeBrokerUrl(s));
523
- } catch {
524
- log.warn(`mesh: ignoring unparsable broker entry "${s}"`);
525
- }
526
- }
527
- return out;
528
- } catch {
529
- return [];
530
- }
531
- }
532
856
  function normalizeBrokerUrl(s) {
533
857
  s = s.trim();
534
858
  if (!/^https?:\/\//.test(s)) s = `http://${s}`;
@@ -550,8 +874,84 @@ function postJson(url, headers, body, timeoutMs) {
550
874
  });
551
875
  }
552
876
 
877
+ function meshExpireMs(announceMs) {
878
+ return announceMs * 3 + 5e3;
879
+ }
880
+ class MeshPeerTable {
881
+ constructor(opts) {
882
+ this.opts = opts;
883
+ this.now = opts.now ?? Date.now;
884
+ }
885
+ byId = /* @__PURE__ */ new Map();
886
+ mismatchLogged = /* @__PURE__ */ new Set();
887
+ now;
888
+ /** Current sync targets. */
889
+ urls() {
890
+ return [...this.byId.values()].map((e) => e.url);
891
+ }
892
+ entries() {
893
+ return [...this.byId.values()];
894
+ }
895
+ /** `announce` packet from `address` (UDP source). Returns true when accepted. */
896
+ handleAnnounce(m, address) {
897
+ if (m[DISCOVERY_KEY] !== DISCOVERY_ANNOUNCE_VALUE || m.mesh !== true) return false;
898
+ const ts = m.ts;
899
+ if (typeof ts !== "number" || !Number.isFinite(ts) || Math.abs(this.now() - ts) > MESH_ANNOUNCE_MAX_SKEW_MS) return false;
900
+ return this.accept(m, address, String(ts));
901
+ }
902
+ /** `broker` reply to our probe that carried `nonce`. Returns true when accepted. */
903
+ handleReply(m, address, nonce) {
904
+ if (m[DISCOVERY_KEY] !== DISCOVERY_REPLY_VALUE || m.mesh !== true) return false;
905
+ return this.accept(m, address, nonce);
906
+ }
907
+ accept(m, address, nonce) {
908
+ const { id, port, version, mac } = m;
909
+ if (typeof id !== "string" || !id || typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) return false;
910
+ if (typeof mac !== "string") return false;
911
+ if (!meshMacEqual(meshMac(this.opts.token, nonce, id, port), mac)) {
912
+ const key = `${id}@${address}`;
913
+ if (!this.mismatchLogged.has(key)) {
914
+ this.mismatchLogged.add(key);
915
+ log.warn(`mesh: ignoring ${id}@${address} (token mismatch)`);
916
+ }
917
+ return false;
918
+ }
919
+ if (id === this.opts.selfId) return false;
920
+ const url = `http://${address}:${port}`;
921
+ const prev = this.byId.get(id);
922
+ const entry = { id, url, version: typeof version === "string" ? version : "", lastSeen: this.now() };
923
+ this.byId.set(id, entry);
924
+ if (!prev) {
925
+ log.info(`mesh: found ${id} at ${url}`);
926
+ this.opts.onJoin?.(entry);
927
+ } else if (prev.url !== url) {
928
+ log.info(`mesh: ${id} moved ${prev.url} -> ${url}`);
929
+ this.opts.onDrop?.(prev.url);
930
+ this.opts.onJoin?.(entry);
931
+ }
932
+ return true;
933
+ }
934
+ /**
935
+ * Drop entries not vouched for within `expireMs` whose last sync did not succeed (`linkUp`): a link that
936
+ * still syncs stays even when UDP is lost. Returns the removed entries.
937
+ */
938
+ sweep(linkUp) {
939
+ const gone = [];
940
+ const t = this.now();
941
+ for (const e of this.byId.values()) {
942
+ if (t - e.lastSeen <= this.opts.expireMs || linkUp(e.url)) continue;
943
+ this.byId.delete(e.id);
944
+ gone.push(e);
945
+ log.info(`mesh: ${e.url} left`);
946
+ this.opts.onDrop?.(e.url);
947
+ }
948
+ return gone;
949
+ }
950
+ }
951
+
553
952
  function createBrokerApp(opts) {
554
953
  const routes = opts.handlers.routes();
954
+ const protocolRoutes = opts.handlers.protocolRoutes();
555
955
  const router = createRouter();
556
956
  router.get(HEALTH_ROUTE, defineEventHandler(() => ({
557
957
  status: "ok",
@@ -559,7 +959,8 @@ function createBrokerApp(opts) {
559
959
  id: opts.id,
560
960
  port: opts.port(),
561
961
  peers: opts.handlers.alivePeerCount(),
562
- ...opts.fallback ? { fallback: true } : {}
962
+ ...opts.fallback ? { fallback: true } : {},
963
+ ...opts.mesh ? { mesh: true } : {}
563
964
  })));
564
965
  const post = (route, run) => router.post(route, defineEventHandler(async (event) => {
565
966
  if (!authorized(event, opts.token)) return unauthorized(event);
@@ -572,6 +973,7 @@ function createBrokerApp(opts) {
572
973
  }));
573
974
  for (const route of BROKER_POST_ROUTES) post(route, (body) => routes[route](body));
574
975
  post(RELAY_ROUTE, (body) => opts.handlers.relayMessages(body));
976
+ for (const route of PROTOCOL_ROUTES) post(route, (body) => protocolRoutes[route](body));
575
977
  if (opts.mesh) post(MESH_SYNC_ROUTE, (body) => opts.handlers.meshSync(body));
576
978
  const app = createApp();
577
979
  app.use(router);
@@ -602,12 +1004,16 @@ function startDiscoveryResponder(opts) {
602
1004
  const disco = createSocket({ type: "udp4", reuseAddr: true });
603
1005
  disco.on("error", (e) => log.warn(`discovery responder error: ${e.message}`));
604
1006
  disco.on("message", (msg, rinfo) => {
1007
+ let m;
605
1008
  try {
606
- if (JSON.parse(msg.toString())[DISCOVERY_KEY] !== DISCOVERY_PROBE_VALUE) return;
1009
+ m = JSON.parse(msg.toString());
607
1010
  } catch {
608
1011
  return;
609
1012
  }
610
- const reply = Buffer.from(JSON.stringify(opts.reply()));
1013
+ if (m[DISCOVERY_KEY] === DISCOVERY_ANNOUNCE_VALUE) return opts.onAnnounce?.(m, rinfo);
1014
+ if (m[DISCOVERY_KEY] !== DISCOVERY_PROBE_VALUE) return;
1015
+ const nonce = typeof m.n === "string" && m.n.length > 0 && m.n.length <= 128 ? m.n : void 0;
1016
+ const reply = Buffer.from(JSON.stringify(opts.reply(nonce)));
611
1017
  disco.send(reply, rinfo.port, rinfo.address, () => {
612
1018
  });
613
1019
  });
@@ -624,6 +1030,48 @@ function startDiscoveryResponder(opts) {
624
1030
  });
625
1031
  });
626
1032
  }
1033
+ function parseDiscoveryTargets(list) {
1034
+ const out = [];
1035
+ for (const s of list) {
1036
+ const i = s.lastIndexOf(":");
1037
+ const port = Number(s.slice(i + 1));
1038
+ if (i <= 0 || !Number.isInteger(port) || port < 1 || port > 65535) {
1039
+ log.warn(`discovery: ignoring invalid target "${s}" (want host:port)`);
1040
+ continue;
1041
+ }
1042
+ out.push({ host: s.slice(0, i), port });
1043
+ }
1044
+ return out;
1045
+ }
1046
+ function sendTargets(opts) {
1047
+ const explicit = parseDiscoveryTargets(opts.targets ?? []);
1048
+ if (explicit.length) return explicit;
1049
+ return probeTargets(opts.addr).map((host) => ({ host, port: opts.port }));
1050
+ }
1051
+ function buildAnnounce(a) {
1052
+ return {
1053
+ [DISCOVERY_KEY]: DISCOVERY_ANNOUNCE_VALUE,
1054
+ id: a.id,
1055
+ port: a.port,
1056
+ version: a.version,
1057
+ mesh: true,
1058
+ ts: a.ts,
1059
+ mac: meshMac(a.token, String(a.ts), a.id, a.port)
1060
+ };
1061
+ }
1062
+ function sendAnnounce(sock, packet, targets) {
1063
+ const buf = Buffer.from(JSON.stringify(packet));
1064
+ for (const t of targets) {
1065
+ try {
1066
+ sock.send(buf, t.port, t.host, () => {
1067
+ });
1068
+ } catch {
1069
+ }
1070
+ }
1071
+ }
1072
+ function newNonce() {
1073
+ return randomBytes(16).toString("hex");
1074
+ }
627
1075
  function probeTargets(addr) {
628
1076
  if (addr) return [addr];
629
1077
  const targets = /* @__PURE__ */ new Set(["255.255.255.255"]);
@@ -667,7 +1115,16 @@ function probeBrokers(opts) {
667
1115
  if (m[DISCOVERY_KEY] !== DISCOVERY_REPLY_VALUE || !m.port) return;
668
1116
  const url = `http://${rinfo.address}:${m.port}`;
669
1117
  if (!found.has(url)) {
670
- found.set(url, { url, host: rinfo.address, port: m.port, id: m.id ?? rinfo.address, version: m.version ?? "", fallback: m.fallback === true });
1118
+ found.set(url, {
1119
+ url,
1120
+ host: rinfo.address,
1121
+ port: m.port,
1122
+ id: m.id ?? rinfo.address,
1123
+ version: m.version ?? "",
1124
+ fallback: m.fallback === true,
1125
+ mesh: m.mesh === true,
1126
+ ...typeof m.mac === "string" ? { mac: m.mac } : {}
1127
+ });
671
1128
  }
672
1129
  } catch {
673
1130
  }
@@ -677,8 +1134,8 @@ function probeBrokers(opts) {
677
1134
  sock.setBroadcast(true);
678
1135
  } catch {
679
1136
  }
680
- const probe = Buffer.from(JSON.stringify({ [DISCOVERY_KEY]: DISCOVERY_PROBE_VALUE }));
681
- for (const t of probeTargets(opts.addr)) sock.send(probe, opts.port, t, () => {
1137
+ const probe = Buffer.from(JSON.stringify({ [DISCOVERY_KEY]: DISCOVERY_PROBE_VALUE, ...opts.nonce ? { n: opts.nonce } : {} }));
1138
+ for (const t of sendTargets(opts)) sock.send(probe, t.port, t.host, () => {
682
1139
  });
683
1140
  });
684
1141
  });
@@ -707,7 +1164,8 @@ async function relayUndelivered(state, url, token) {
707
1164
  to_hostname: peer.hostname,
708
1165
  text: m.text,
709
1166
  mode: m.mode ?? "pull",
710
- sent_at: m.sent_at
1167
+ sent_at: m.sent_at,
1168
+ ...m.meta ? { meta: m.meta } : {}
711
1169
  });
712
1170
  }
713
1171
  let sent = 0;
@@ -719,7 +1177,7 @@ async function relayUndelivered(state, url, token) {
719
1177
  dropped += r.dropped;
720
1178
  } else if (res.status === 404) {
721
1179
  for (const it of items) {
722
- const r = await post(url, "/send-message", token, { to: it.to_name, from_id: it.from_id, from_name: it.from_name, text: it.text, mode: it.mode });
1180
+ const r = await post(url, "/send-message", token, { to: it.to_name, from_id: it.from_id, from_name: it.from_name, text: it.text, mode: it.mode, meta: it.meta });
723
1181
  if (r.ok && (await r.json()).ok) sent++;
724
1182
  else dropped++;
725
1183
  }
@@ -733,7 +1191,7 @@ async function relayUndelivered(state, url, token) {
733
1191
  return { sent, dropped };
734
1192
  }
735
1193
 
736
- const version = "0.9.0";
1194
+ const version = "0.11.0";
737
1195
  const pkg = {
738
1196
  version: version};
739
1197
 
@@ -779,21 +1237,61 @@ async function startBroker(options = {}) {
779
1237
  throw e;
780
1238
  }
781
1239
  port = listener.address.port;
782
- const reply = () => ({ [DISCOVERY_KEY]: DISCOVERY_REPLY_VALUE, id: cfg.id, port, version: VERSION, ...cfg.fallback ? { fallback: true } : {} });
1240
+ const reply = (nonce) => ({
1241
+ [DISCOVERY_KEY]: DISCOVERY_REPLY_VALUE,
1242
+ id: cfg.id,
1243
+ port,
1244
+ version: VERSION,
1245
+ ...cfg.fallback ? { fallback: true } : {},
1246
+ ...cfg.mesh ? { mesh: true, ...nonce ? { mac: meshMac(cfg.token, nonce, cfg.id, port) } : {} } : {}
1247
+ });
1248
+ const fixed = cfg.meshBrokers.map(normalizeBrokerUrl);
1249
+ const peerTable = cfg.mesh ? new MeshPeerTable({
1250
+ selfId: cfg.id,
1251
+ token: cfg.token,
1252
+ expireMs: meshExpireMs(cfg.meshAnnounceMs),
1253
+ onDrop: (url) => mesh?.forget(url),
1254
+ onJoin: () => mesh?.kick()
1255
+ }) : null;
783
1256
  let disco = null;
784
1257
  if (cfg.discovery) {
785
- disco = await startDiscoveryResponder({ port: cfg.discoveryPort, addr: cfg.discoveryAddr, reply });
1258
+ disco = await startDiscoveryResponder({
1259
+ port: cfg.discoveryPort,
1260
+ addr: cfg.discoveryAddr,
1261
+ reply,
1262
+ ...peerTable ? { onAnnounce: (m, rinfo) => void peerTable.handleAnnounce(m, rinfo.address) } : {}
1263
+ });
786
1264
  }
1265
+ let announceTimer = null;
787
1266
  if (cfg.mesh) {
788
- const fixed = cfg.meshBrokers.map(normalizeBrokerUrl);
789
1267
  mesh = new Mesh({
790
1268
  id: cfg.id,
791
1269
  token: cfg.token,
792
1270
  handlers,
793
- urls: () => fixed.length ? fixed : readMeshBrokers(cfg.configFile),
1271
+ urls: () => fixed.length ? fixed : peerTable.urls(),
794
1272
  intervalMs: cfg.meshIntervalMs
795
1273
  });
796
1274
  mesh.start();
1275
+ if (!cfg.discovery && !fixed.length) {
1276
+ log.warn("mesh: AGENT_MESSENGER_MESH=on but AGENT_MESSENGER_DISCOVERY=off; no other mesh broker can be found");
1277
+ }
1278
+ if (cfg.discovery && disco && !fixed.length) {
1279
+ const sock = disco;
1280
+ const targets = sendTargets({ port: cfg.discoveryPort, addr: cfg.discoveryAddr, targets: cfg.discoveryTargets });
1281
+ const announceAndSweep = () => {
1282
+ sendAnnounce(sock, buildAnnounce({ token: cfg.token, id: cfg.id, port, version: VERSION, ts: Date.now() }), targets);
1283
+ peerTable.sweep((url) => mesh?.linkUp(url) ?? false);
1284
+ };
1285
+ announceAndSweep();
1286
+ announceTimer = setInterval(announceAndSweep, cfg.meshAnnounceMs);
1287
+ announceTimer.unref();
1288
+ const nonce = newNonce();
1289
+ void probeBrokers({ port: cfg.discoveryPort, addr: cfg.discoveryAddr, targets: cfg.discoveryTargets, nonce, timeoutMs: 1e3 }).then((found) => {
1290
+ for (const b of found) {
1291
+ peerTable.handleReply({ [DISCOVERY_KEY]: DISCOVERY_REPLY_VALUE, id: b.id, port: b.port, version: b.version, mesh: b.mesh, mac: b.mac }, b.host, nonce);
1292
+ }
1293
+ });
1294
+ }
797
1295
  }
798
1296
  log.info(`v${VERSION} id=${cfg.id} listening on ${cfg.bind}:${port} (state: ${cfg.stateFile})`);
799
1297
  if (cfg.fallback) log.info(`FALLBACK mode: looking for another broker every ${cfg.fallbackIntervalMs} ms (discovery ${cfg.discovery ? "on" : "off"})`);
@@ -805,6 +1303,7 @@ async function startBroker(options = {}) {
805
1303
  closed = true;
806
1304
  clearInterval(cleaner);
807
1305
  if (fallbackTimer) clearInterval(fallbackTimer);
1306
+ if (announceTimer) clearInterval(announceTimer);
808
1307
  mesh?.stop();
809
1308
  if (disco) {
810
1309
  await new Promise((resolve) => disco.close(() => resolve()));
@@ -819,7 +1318,7 @@ async function startBroker(options = {}) {
819
1318
  if (busy || closed) return;
820
1319
  busy = true;
821
1320
  try {
822
- const found = await probeBrokers({ port: cfg.discoveryPort, addr: cfg.discoveryAddr, timeoutMs: 1e3 });
1321
+ const found = await probeBrokers({ port: cfg.discoveryPort, addr: cfg.discoveryAddr, targets: cfg.discoveryTargets, timeoutMs: 1e3 });
823
1322
  const other = found.find((b) => !b.fallback && !(b.port === port && mine.has(b.host)));
824
1323
  if (!other || closed) return;
825
1324
  log.info(`fallback: found broker ${other.id} at ${other.url} \u2014 relaying messages and exiting`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unagents/messenger-broker",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "agent-messenger broker: HTTP daemon that tracks peers and routes messages between Claude Code sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "test": "vitest run"
29
29
  },
30
30
  "dependencies": {
31
- "@unagents/messenger-protocol": "0.9.0",
31
+ "@unagents/messenger-protocol": "0.11.0",
32
32
  "consola": "^3.4.2",
33
33
  "defu": "^6.1.7",
34
34
  "h3": "^1.15.11",