@unagents/messenger-broker 0.9.0 → 0.10.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)`;
|
|
@@ -78,6 +78,8 @@ interface BrokerStateFile {
|
|
|
78
78
|
nextMsgId: number;
|
|
79
79
|
/** Relayed messages waiting for their recipient to register (absent in files written before the fallback relay). */
|
|
80
80
|
pending?: PendingRelay[];
|
|
81
|
+
/** Protocol definitions (absent in files written before the protocol registry, or when none is held). */
|
|
82
|
+
protocols?: StoredProtocol[];
|
|
81
83
|
}
|
|
82
84
|
/** A message relayed from a fallback broker whose recipient has not registered here yet. */
|
|
83
85
|
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)`;
|
|
@@ -78,6 +78,8 @@ interface BrokerStateFile {
|
|
|
78
78
|
nextMsgId: number;
|
|
79
79
|
/** Relayed messages waiting for their recipient to register (absent in files written before the fallback relay). */
|
|
80
80
|
pending?: PendingRelay[];
|
|
81
|
+
/** Protocol definitions (absent in files written before the protocol registry, or when none is held). */
|
|
82
|
+
protocols?: StoredProtocol[];
|
|
81
83
|
}
|
|
82
84
|
/** A message relayed from a fallback broker whose recipient has not registered here yet. */
|
|
83
85
|
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.
|
|
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.B8W77sqB.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.
|
|
1
|
+
import { s as startBroker, l as log } from './shared/messenger-broker.B8W77sqB.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, 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, HEALTH_ROUTE, BROKER_POST_ROUTES, PROTOCOL_ROUTES, RELAY_ROUTE, DISCOVERY_KEY, DISCOVERY_PROBE_VALUE, DISCOVERY_REPLY_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 } from 'node:crypto';
|
|
9
10
|
import { request } from 'node:http';
|
|
10
11
|
import { createSocket } from 'node:dgram';
|
|
11
12
|
|
|
@@ -54,6 +55,8 @@ class BrokerState {
|
|
|
54
55
|
messages = [];
|
|
55
56
|
nextMsgId = 1;
|
|
56
57
|
pending = [];
|
|
58
|
+
/** Protocol definitions by `"name@version"`. Kept while at least one alive peer declares them (see `BrokerHandlers.gcProtocols`). */
|
|
59
|
+
protocols = /* @__PURE__ */ new Map();
|
|
57
60
|
/** Mesh: when each origin broker last synced with us (ms epoch, memory only). Replicated peers live as long as this is fresh. */
|
|
58
61
|
originSeen = /* @__PURE__ */ new Map();
|
|
59
62
|
saveTimer = null;
|
|
@@ -68,7 +71,12 @@ class BrokerState {
|
|
|
68
71
|
this.messages = raw.messages ?? [];
|
|
69
72
|
this.nextMsgId = raw.nextMsgId ?? 1;
|
|
70
73
|
this.pending = raw.pending ?? [];
|
|
71
|
-
|
|
74
|
+
for (const sp of raw.protocols ?? []) {
|
|
75
|
+
if (sp && typeof sp.name === "string" && typeof sp.version === "string") this.protocols.set(`${sp.name}@${sp.version}`, sp);
|
|
76
|
+
}
|
|
77
|
+
log.info(
|
|
78
|
+
`Loaded state from ${this.file} (${this.peers.size} peer(s), ${this.messages.length} message(s)${this.protocols.size ? `, ${this.protocols.size} protocol(s)` : ""})`
|
|
79
|
+
);
|
|
72
80
|
} catch {
|
|
73
81
|
}
|
|
74
82
|
}
|
|
@@ -93,7 +101,8 @@ class BrokerState {
|
|
|
93
101
|
peers: [...this.peers.values()],
|
|
94
102
|
messages: this.messages,
|
|
95
103
|
nextMsgId: this.nextMsgId,
|
|
96
|
-
...this.pending.length ? { pending: this.pending } : {}
|
|
104
|
+
...this.pending.length ? { pending: this.pending } : {},
|
|
105
|
+
...this.protocols.size ? { protocols: [...this.protocols.values()] } : {}
|
|
97
106
|
};
|
|
98
107
|
writeFileSync(tmp, JSON.stringify(snapshot, null, 1));
|
|
99
108
|
renameSync(tmp, this.file);
|
|
@@ -107,6 +116,112 @@ class BrokerState {
|
|
|
107
116
|
}
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
120
|
+
const HASH_PATTERN = /^[0-9a-f]{64}$/;
|
|
121
|
+
const MAX_FIELD_LENGTH = 1024;
|
|
122
|
+
function protocolHash(manifest, doc) {
|
|
123
|
+
return createHash("sha256").update(canonicalJSON({ manifest, doc })).digest("hex");
|
|
124
|
+
}
|
|
125
|
+
function isPlainObject(v) {
|
|
126
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
127
|
+
}
|
|
128
|
+
function checkDefinition(name, version, def) {
|
|
129
|
+
const bad = (error) => ({ ok: false, code: "INVALID_DEFINITION", error });
|
|
130
|
+
if (typeof name !== "string" || !PROTOCOL_NAME_PATTERN.test(name)) {
|
|
131
|
+
return bad(`invalid protocol name (expected ${String(PROTOCOL_NAME_PATTERN)})`);
|
|
132
|
+
}
|
|
133
|
+
if (typeof version !== "string" || version.length > MAX_FIELD_LENGTH || !VERSION_PATTERN.test(version)) {
|
|
134
|
+
return bad("invalid version (expected semver, e.g. 1.2.0 or 0.0.0+abc1234)");
|
|
135
|
+
}
|
|
136
|
+
if (!isPlainObject(def)) return bad("missing definition");
|
|
137
|
+
const { manifest, doc, source, ref } = def;
|
|
138
|
+
if (!isPlainObject(manifest)) return bad("definition.manifest must be an object");
|
|
139
|
+
if (typeof doc !== "string") return bad("definition.doc must be a string");
|
|
140
|
+
if (typeof source !== "string" || !source || source.length > MAX_FIELD_LENGTH) return bad("definition.source must be a non-empty string");
|
|
141
|
+
if (typeof ref !== "string" || !ref || ref.length > MAX_FIELD_LENGTH) return bad("definition.ref must be a non-empty string");
|
|
142
|
+
if (manifest.name !== name) return bad(`protocol.json "name" must equal "${name}"`);
|
|
143
|
+
if (manifest.description !== void 0 && typeof manifest.description !== "string") return bad('protocol.json "description" must be a string');
|
|
144
|
+
if (manifest.messages !== void 0) {
|
|
145
|
+
if (!isPlainObject(manifest.messages)) return bad('protocol.json "messages" must be an object');
|
|
146
|
+
for (const [k, schema] of Object.entries(manifest.messages)) {
|
|
147
|
+
if (!isPlainObject(schema)) return bad(`protocol.json messages.${k} must be an object (JSON Schema)`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const manifestBytes = Buffer.byteLength(JSON.stringify(manifest));
|
|
151
|
+
const docBytes = Buffer.byteLength(doc);
|
|
152
|
+
if (manifestBytes > PROTOCOL_MANIFEST_MAX_BYTES) {
|
|
153
|
+
return { ok: false, code: "TOO_LARGE", error: `protocol.json is ${manifestBytes} bytes (limit ${PROTOCOL_MANIFEST_MAX_BYTES})` };
|
|
154
|
+
}
|
|
155
|
+
if (docBytes > PROTOCOL_DOC_MAX_BYTES) {
|
|
156
|
+
return { ok: false, code: "TOO_LARGE", error: `PROTOCOL.md is ${docBytes} bytes (limit ${PROTOCOL_DOC_MAX_BYTES})` };
|
|
157
|
+
}
|
|
158
|
+
if (manifestBytes + docBytes > PROTOCOL_TOTAL_MAX_BYTES) {
|
|
159
|
+
return { ok: false, code: "TOO_LARGE", error: `definition is ${manifestBytes + docBytes} bytes (limit ${PROTOCOL_TOTAL_MAX_BYTES})` };
|
|
160
|
+
}
|
|
161
|
+
return { ok: true };
|
|
162
|
+
}
|
|
163
|
+
function sanitizeDecls(list) {
|
|
164
|
+
if (!Array.isArray(list)) return [];
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const d of list) {
|
|
167
|
+
if (!isPlainObject(d)) continue;
|
|
168
|
+
const { name, version, hash } = d;
|
|
169
|
+
if (typeof name !== "string" || !PROTOCOL_NAME_PATTERN.test(name)) continue;
|
|
170
|
+
if (typeof version !== "string" || version.length > MAX_FIELD_LENGTH || !VERSION_PATTERN.test(version)) continue;
|
|
171
|
+
if (typeof hash !== "string" || !HASH_PATTERN.test(hash)) continue;
|
|
172
|
+
if (out.some((o) => o.name === name)) continue;
|
|
173
|
+
out.push({ name, version, hash });
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
function checkMeta(meta) {
|
|
178
|
+
if (!isPlainObject(meta)) return null;
|
|
179
|
+
const { protocol, version } = meta;
|
|
180
|
+
if (typeof protocol !== "string" || typeof version !== "string") return null;
|
|
181
|
+
if (protocol.length > PROTOCOL_META_MAX_LENGTH || version.length > PROTOCOL_META_MAX_LENGTH) return null;
|
|
182
|
+
return { protocol, version };
|
|
183
|
+
}
|
|
184
|
+
function isTagless(v) {
|
|
185
|
+
return v.startsWith("0.0.0+");
|
|
186
|
+
}
|
|
187
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/;
|
|
188
|
+
function compareVersions(a, b) {
|
|
189
|
+
const ta = isTagless(a);
|
|
190
|
+
const tb = isTagless(b);
|
|
191
|
+
if (ta || tb) return ta && tb ? 0 : ta ? -1 : 1;
|
|
192
|
+
const ma = SEMVER.exec(a);
|
|
193
|
+
const mb = SEMVER.exec(b);
|
|
194
|
+
if (!ma || !mb) return a < b ? -1 : a > b ? 1 : 0;
|
|
195
|
+
for (let i = 1; i <= 3; i++) {
|
|
196
|
+
const x = BigInt(ma[i]);
|
|
197
|
+
const y = BigInt(mb[i]);
|
|
198
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
199
|
+
}
|
|
200
|
+
const pa = ma[4];
|
|
201
|
+
const pb = mb[4];
|
|
202
|
+
if (pa === void 0 && pb === void 0) return 0;
|
|
203
|
+
if (pa === void 0) return 1;
|
|
204
|
+
if (pb === void 0) return -1;
|
|
205
|
+
const ia = pa.split(".");
|
|
206
|
+
const ib = pb.split(".");
|
|
207
|
+
for (let i = 0; i < Math.min(ia.length, ib.length); i++) {
|
|
208
|
+
const x = ia[i];
|
|
209
|
+
const y = ib[i];
|
|
210
|
+
const nx = /^\d+$/.test(x);
|
|
211
|
+
const ny = /^\d+$/.test(y);
|
|
212
|
+
if (nx && ny) {
|
|
213
|
+
const dx = BigInt(x);
|
|
214
|
+
const dy = BigInt(y);
|
|
215
|
+
if (dx !== dy) return dx < dy ? -1 : 1;
|
|
216
|
+
} else if (nx !== ny) {
|
|
217
|
+
return nx ? -1 : 1;
|
|
218
|
+
} else if (x !== y) {
|
|
219
|
+
return x < y ? -1 : 1;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return ia.length === ib.length ? 0 : ia.length < ib.length ? -1 : 1;
|
|
223
|
+
}
|
|
224
|
+
|
|
110
225
|
const PENDING_RELAY_TTL_MS = 60 * 60 * 1e3;
|
|
111
226
|
const DEFAULT_MESH_PEER_TTL_MS = 3e5;
|
|
112
227
|
const MESH_KEY_MEMORY = 2e3;
|
|
@@ -116,6 +231,8 @@ class BrokerHandlers {
|
|
|
116
231
|
this.opts = opts;
|
|
117
232
|
}
|
|
118
233
|
recentMeshKeys = /* @__PURE__ */ new Set();
|
|
234
|
+
/** Mesh protocol conflicts already logged (`name@version:hash`), so a standing conflict warns once, not every round. */
|
|
235
|
+
warnedProtocolConflicts = /* @__PURE__ */ new Set();
|
|
119
236
|
// --- peer bookkeeping ---
|
|
120
237
|
/** Cutoff `last_seen` (ISO) below which a peer counts as stale. */
|
|
121
238
|
static cutoff() {
|
|
@@ -158,6 +275,56 @@ class BrokerHandlers {
|
|
|
158
275
|
this.state.pending = kept;
|
|
159
276
|
this.state.scheduleSave();
|
|
160
277
|
}
|
|
278
|
+
this.gcProtocols();
|
|
279
|
+
}
|
|
280
|
+
// --- protocol store ---
|
|
281
|
+
/** Alive peers (replicas included) that declare `name@version`. */
|
|
282
|
+
serversOf(name, version) {
|
|
283
|
+
const cutoff = BrokerHandlers.cutoff();
|
|
284
|
+
return [...this.state.peers.values()].filter(
|
|
285
|
+
(p) => this.isAlive(p, cutoff) && p.protocols?.some((d) => d.name === name && d.version === version)
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Drop stored definitions that no alive peer (replicas included) declares by `(name, version)` any more.
|
|
290
|
+
* Runs after `cleanStalePeers`, `register`, `unregister`, `/set-protocol`, `/unset-protocol`, `meshApplyPeers`
|
|
291
|
+
* and before `/get-protocol` answers.
|
|
292
|
+
*/
|
|
293
|
+
gcProtocols() {
|
|
294
|
+
if (this.state.protocols.size === 0) return;
|
|
295
|
+
const cutoff = BrokerHandlers.cutoff();
|
|
296
|
+
const live = /* @__PURE__ */ new Set();
|
|
297
|
+
for (const p of this.state.peers.values()) {
|
|
298
|
+
if (!this.isAlive(p, cutoff)) continue;
|
|
299
|
+
for (const d of p.protocols ?? []) live.add(`${d.name}@${d.version}`);
|
|
300
|
+
}
|
|
301
|
+
let removed = 0;
|
|
302
|
+
for (const key of [...this.state.protocols.keys()]) {
|
|
303
|
+
if (!live.has(key)) {
|
|
304
|
+
this.state.protocols.delete(key);
|
|
305
|
+
removed++;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (removed) {
|
|
309
|
+
log.info(`Dropped ${removed} protocol definition(s) without a declaring peer`);
|
|
310
|
+
this.state.scheduleSave();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Record `decls` on `peer` (hash only). A name the peer already declares is left alone (only `/set-protocol`
|
|
315
|
+
* changes a declaration, so a heartbeat that raced a change cannot undo it), and so is a declaration whose hash
|
|
316
|
+
* differs from the definition stored for the same `(name, version)`.
|
|
317
|
+
*/
|
|
318
|
+
mergeDecls(peer, decls) {
|
|
319
|
+
let changed = false;
|
|
320
|
+
for (const d of decls) {
|
|
321
|
+
if (peer.protocols?.some((x) => x.name === d.name)) continue;
|
|
322
|
+
const stored = this.state.protocols.get(`${d.name}@${d.version}`);
|
|
323
|
+
if (stored && stored.hash !== d.hash) continue;
|
|
324
|
+
peer.protocols = [...peer.protocols ?? [], d];
|
|
325
|
+
changed = true;
|
|
326
|
+
}
|
|
327
|
+
return changed;
|
|
161
328
|
}
|
|
162
329
|
/** A peer that registered here (not a mesh replica). Only those can heartbeat / rename. */
|
|
163
330
|
localPeer(id) {
|
|
@@ -206,7 +373,10 @@ class BrokerHandlers {
|
|
|
206
373
|
registered_at: now,
|
|
207
374
|
last_seen: now
|
|
208
375
|
});
|
|
209
|
-
this.
|
|
376
|
+
const peer = this.state.peers.get(id);
|
|
377
|
+
if (body.protocols !== void 0) this.mergeDecls(peer, sanitizeDecls(body.protocols));
|
|
378
|
+
this.deliverPending(peer);
|
|
379
|
+
this.gcProtocols();
|
|
210
380
|
this.state.scheduleSave();
|
|
211
381
|
return { id };
|
|
212
382
|
}
|
|
@@ -214,8 +384,10 @@ class BrokerHandlers {
|
|
|
214
384
|
const p = this.localPeer(body.id);
|
|
215
385
|
if (!p) return { ok: false, error: "unknown peer (re-register)" };
|
|
216
386
|
p.last_seen = (/* @__PURE__ */ new Date()).toISOString();
|
|
387
|
+
if (body.protocols !== void 0) this.mergeDecls(p, sanitizeDecls(body.protocols));
|
|
217
388
|
this.state.scheduleSave();
|
|
218
|
-
|
|
389
|
+
const resend = (p.protocols ?? []).filter((d) => !this.state.protocols.has(`${d.name}@${d.version}`)).map((d) => `${d.name}@${d.version}`);
|
|
390
|
+
return resend.length ? { ok: true, resend } : { ok: true };
|
|
219
391
|
}
|
|
220
392
|
setSummary(body) {
|
|
221
393
|
const p = this.localPeer(body.id);
|
|
@@ -247,6 +419,10 @@ class BrokerHandlers {
|
|
|
247
419
|
break;
|
|
248
420
|
}
|
|
249
421
|
if (body.exclude_id) list = list.filter((p) => p.id !== body.exclude_id);
|
|
422
|
+
if (body.protocol) {
|
|
423
|
+
const { protocol, version } = body;
|
|
424
|
+
list = list.filter((p) => p.protocols?.some((d) => d.name === protocol && (!version || d.version === version)));
|
|
425
|
+
}
|
|
250
426
|
return list.map(({ origin: _origin, ...p }) => p);
|
|
251
427
|
}
|
|
252
428
|
sendMessage(body) {
|
|
@@ -255,6 +431,12 @@ class BrokerHandlers {
|
|
|
255
431
|
const { peer } = resolved;
|
|
256
432
|
const mode = body.mode ?? "pull";
|
|
257
433
|
if (mode !== "pull" && mode !== "push" && mode !== "ping") return { ok: false, error: `invalid mode: ${String(mode)}` };
|
|
434
|
+
let meta;
|
|
435
|
+
if (body.meta !== void 0) {
|
|
436
|
+
const checked = checkMeta(body.meta);
|
|
437
|
+
if (!checked) return { ok: false, error: "invalid meta: expected {protocol: string, version: string}, each at most 128 characters" };
|
|
438
|
+
meta = checked;
|
|
439
|
+
}
|
|
258
440
|
const msg = {
|
|
259
441
|
id: this.state.nextMsgId++,
|
|
260
442
|
from_id: body.from_id ?? "cli",
|
|
@@ -264,7 +446,8 @@ class BrokerHandlers {
|
|
|
264
446
|
mode,
|
|
265
447
|
sent_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
266
448
|
delivered: 0,
|
|
267
|
-
acknowledged: 0
|
|
449
|
+
acknowledged: 0,
|
|
450
|
+
...meta ? { meta } : {}
|
|
268
451
|
};
|
|
269
452
|
this.state.messages.push(msg);
|
|
270
453
|
this.state.scheduleSave();
|
|
@@ -293,12 +476,99 @@ class BrokerHandlers {
|
|
|
293
476
|
}
|
|
294
477
|
unregister(body) {
|
|
295
478
|
this.removePeer(body.id);
|
|
479
|
+
this.gcProtocols();
|
|
480
|
+
this.state.scheduleSave();
|
|
481
|
+
return { ok: true };
|
|
482
|
+
}
|
|
483
|
+
// --- protocol registry ---
|
|
484
|
+
protocolError(code, error, extra = {}) {
|
|
485
|
+
return { ok: false, error, code, ...extra };
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* `/set-protocol`. Order: unknown peer, definition shape / size (INVALID_DEFINITION, TOO_LARGE), hash
|
|
489
|
+
* re-computation (HASH_MISMATCH), different hash for the same `(name, version)` (PROTOCOL_CONFLICT), held
|
|
490
|
+
* definitions limit (LIMIT). The same hash again is a no-op success, so a client may re-send at will.
|
|
491
|
+
*/
|
|
492
|
+
setProtocol(body) {
|
|
493
|
+
const peer = this.localPeer(body.id);
|
|
494
|
+
if (!peer) return { ok: false, error: "unknown peer" };
|
|
495
|
+
const checked = checkDefinition(body.name, body.version, body.definition);
|
|
496
|
+
if (!checked.ok) return this.protocolError(checked.code, checked.error);
|
|
497
|
+
const { name, version, definition } = body;
|
|
498
|
+
const hash = protocolHash(definition.manifest, definition.doc);
|
|
499
|
+
if (body.hash !== hash) {
|
|
500
|
+
return this.protocolError("HASH_MISMATCH", `hash mismatch for ${name}@${version}: sent ${String(body.hash)}, computed ${hash}`);
|
|
501
|
+
}
|
|
502
|
+
this.gcProtocols();
|
|
503
|
+
const key = `${name}@${version}`;
|
|
504
|
+
const existing = this.state.protocols.get(key);
|
|
505
|
+
if (existing && existing.hash !== hash) {
|
|
506
|
+
const servers = this.serversOf(name, version).map((p) => `${p.name || p.id}@${p.hostname}`);
|
|
507
|
+
return this.protocolError(
|
|
508
|
+
"PROTOCOL_CONFLICT",
|
|
509
|
+
`${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`,
|
|
510
|
+
{ existing_hash: existing.hash, servers }
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
if (!existing) {
|
|
514
|
+
if (this.state.protocols.size >= PROTOCOL_MAX_STORED) {
|
|
515
|
+
return this.protocolError("LIMIT", `this broker already holds ${PROTOCOL_MAX_STORED} protocol definitions (limit)`);
|
|
516
|
+
}
|
|
517
|
+
this.state.protocols.set(key, {
|
|
518
|
+
name,
|
|
519
|
+
version,
|
|
520
|
+
hash,
|
|
521
|
+
definition: { manifest: definition.manifest, doc: definition.doc, source: definition.source, ref: definition.ref },
|
|
522
|
+
registered_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
peer.protocols = [...(peer.protocols ?? []).filter((d) => d.name !== name), { name, version, hash }];
|
|
526
|
+
this.gcProtocols();
|
|
296
527
|
this.state.scheduleSave();
|
|
297
528
|
return { ok: true };
|
|
298
529
|
}
|
|
530
|
+
/** `/unset-protocol`: drop one declaration by `name`, or all of the peer's. Succeeds with `removed: 0` when there was none. */
|
|
531
|
+
unsetProtocol(body) {
|
|
532
|
+
const peer = this.localPeer(body.id);
|
|
533
|
+
if (!peer) return { ok: false, error: "unknown peer" };
|
|
534
|
+
if (body.name !== void 0 && typeof body.name !== "string") return { ok: false, error: "invalid name" };
|
|
535
|
+
const before = peer.protocols ?? [];
|
|
536
|
+
const kept = body.name === void 0 ? [] : before.filter((d) => d.name !== body.name);
|
|
537
|
+
const removed = before.length - kept.length;
|
|
538
|
+
if (kept.length) peer.protocols = kept;
|
|
539
|
+
else delete peer.protocols;
|
|
540
|
+
if (removed) {
|
|
541
|
+
this.gcProtocols();
|
|
542
|
+
this.state.scheduleSave();
|
|
543
|
+
}
|
|
544
|
+
return { ok: true, removed };
|
|
545
|
+
}
|
|
546
|
+
/** `/get-protocol`: the stored definition of `name` at `version`, or at the highest held version. No round trip to any server. */
|
|
547
|
+
getProtocol(body) {
|
|
548
|
+
if (typeof body.name !== "string" || !body.name) return this.protocolError("PROTOCOL_NOT_FOUND", "protocol name is required", { versions: [] });
|
|
549
|
+
this.gcProtocols();
|
|
550
|
+
const held = [...this.state.protocols.values()].filter((sp) => sp.name === body.name);
|
|
551
|
+
held.sort((a, b) => compareVersions(b.version, a.version) || (a.registered_at < b.registered_at ? 1 : -1));
|
|
552
|
+
const versions = held.map((sp) => sp.version);
|
|
553
|
+
const found = body.version === void 0 ? held[0] : held.find((sp) => sp.version === body.version);
|
|
554
|
+
if (!found) {
|
|
555
|
+
const what = body.version === void 0 ? `"${body.name}"` : `"${body.name}" version "${body.version}"`;
|
|
556
|
+
return this.protocolError("PROTOCOL_NOT_FOUND", `Protocol ${what} not found (registered versions: ${versions.join(", ") || "none"})`, { versions });
|
|
557
|
+
}
|
|
558
|
+
return { ok: true, protocol: found, servers: this.serversOf(found.name, found.version).length };
|
|
559
|
+
}
|
|
560
|
+
/** Route table of the protocol registry in `PROTOCOL_ROUTES` order. */
|
|
561
|
+
protocolRoutes() {
|
|
562
|
+
return {
|
|
563
|
+
"/set-protocol": (b) => this.setProtocol(b),
|
|
564
|
+
"/unset-protocol": (b) => this.unsetProtocol(b),
|
|
565
|
+
"/get-protocol": (b) => this.getProtocol(b)
|
|
566
|
+
};
|
|
567
|
+
}
|
|
299
568
|
// --- relay from a fallback broker ---
|
|
300
569
|
/** Queue a message for `peerId` exactly like `sendMessage` does. */
|
|
301
570
|
enqueue(peerId, m) {
|
|
571
|
+
const meta = m.meta === void 0 ? null : checkMeta(m.meta);
|
|
302
572
|
this.state.messages.push({
|
|
303
573
|
id: this.state.nextMsgId++,
|
|
304
574
|
from_id: m.from_id,
|
|
@@ -308,7 +578,8 @@ class BrokerHandlers {
|
|
|
308
578
|
mode: m.mode ?? "pull",
|
|
309
579
|
sent_at: m.sent_at,
|
|
310
580
|
delivered: 0,
|
|
311
|
-
acknowledged: 0
|
|
581
|
+
acknowledged: 0,
|
|
582
|
+
...meta ? { meta } : {}
|
|
312
583
|
});
|
|
313
584
|
}
|
|
314
585
|
/** Give `peer` the relayed messages that were waiting for its name. */
|
|
@@ -384,8 +655,64 @@ class BrokerHandlers {
|
|
|
384
655
|
for (const p of [...this.state.peers.values()]) {
|
|
385
656
|
if (p.origin === origin && !incoming.has(p.id)) this.removePeer(p.id);
|
|
386
657
|
}
|
|
658
|
+
this.gcProtocols();
|
|
387
659
|
this.state.scheduleSave();
|
|
388
660
|
}
|
|
661
|
+
/** Definitions declared by this broker's direct, alive peers (what a mesh sync sends). */
|
|
662
|
+
meshLocalProtocols() {
|
|
663
|
+
const out = /* @__PURE__ */ new Map();
|
|
664
|
+
for (const p of this.meshLocalPeers()) {
|
|
665
|
+
for (const d of p.protocols ?? []) {
|
|
666
|
+
const key = `${d.name}@${d.version}`;
|
|
667
|
+
const sp = this.state.protocols.get(key);
|
|
668
|
+
if (sp && sp.hash === d.hash) out.set(key, sp);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return [...out.values()];
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Take over definitions another broker's direct peers declare. Only a definition that a peer we now hold
|
|
675
|
+
* declares with the same hash is stored (the rest would be collected at once). A `(name, version)` we already
|
|
676
|
+
* hold with another hash stays as it is (ours wins) and the received one is dropped with a warning.
|
|
677
|
+
*/
|
|
678
|
+
meshApplyProtocols(from, protocols) {
|
|
679
|
+
let added = false;
|
|
680
|
+
for (const sp of protocols) {
|
|
681
|
+
const checked = checkDefinition(sp?.name, sp?.version, sp?.definition);
|
|
682
|
+
if (!checked.ok) {
|
|
683
|
+
log.warn(`mesh: dropped protocol from ${from}: ${checked.error}`);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
const hash = protocolHash(sp.definition.manifest, sp.definition.doc);
|
|
687
|
+
if (hash !== sp.hash) {
|
|
688
|
+
log.warn(`mesh: dropped protocol ${sp.name}@${sp.version} from ${from}: hash mismatch`);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const key = `${sp.name}@${sp.version}`;
|
|
692
|
+
const mine = this.state.protocols.get(key);
|
|
693
|
+
if (mine) {
|
|
694
|
+
if (mine.hash !== hash && !this.warnedProtocolConflicts.has(`${key}:${hash}`)) {
|
|
695
|
+
this.warnedProtocolConflicts.add(`${key}:${hash}`);
|
|
696
|
+
log.warn(`mesh: protocol ${key} from ${from} differs from the one held here (${hash} vs ${mine.hash}); keeping ours`);
|
|
697
|
+
}
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (this.state.protocols.size >= PROTOCOL_MAX_STORED) {
|
|
701
|
+
log.warn(`mesh: dropped protocol ${key} from ${from}: ${PROTOCOL_MAX_STORED} definitions held (limit)`);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
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;
|
|
705
|
+
this.state.protocols.set(key, {
|
|
706
|
+
name: sp.name,
|
|
707
|
+
version: sp.version,
|
|
708
|
+
hash,
|
|
709
|
+
definition: { manifest: sp.definition.manifest, doc: sp.definition.doc, source: sp.definition.source, ref: sp.definition.ref },
|
|
710
|
+
registered_at: typeof sp.registered_at === "string" ? sp.registered_at : (/* @__PURE__ */ new Date()).toISOString()
|
|
711
|
+
});
|
|
712
|
+
added = true;
|
|
713
|
+
}
|
|
714
|
+
if (added) this.state.scheduleSave();
|
|
715
|
+
}
|
|
389
716
|
/** Take over messages forwarded by broker `from`; returns the sender-side ids to drop (duplicates count as taken). */
|
|
390
717
|
meshAccept(from, messages) {
|
|
391
718
|
const accepted = [];
|
|
@@ -409,8 +736,10 @@ class BrokerHandlers {
|
|
|
409
736
|
if (!body.from) throw new Error("mesh sync: missing from");
|
|
410
737
|
if (body.from === this.opts.id) return { id: body.from, peers: [], accepted: [] };
|
|
411
738
|
this.meshApplyPeers(body.from, body.peers ?? []);
|
|
739
|
+
this.meshApplyProtocols(body.from, body.protocols ?? []);
|
|
412
740
|
const accepted = this.meshAccept(body.from, body.messages ?? []);
|
|
413
|
-
|
|
741
|
+
const protocols = this.meshLocalProtocols();
|
|
742
|
+
return { id: this.opts.id ?? "", peers: this.meshLocalPeers(), accepted, ...protocols.length ? { protocols } : {} };
|
|
414
743
|
}
|
|
415
744
|
/** Route table in `BROKER_POST_ROUTES` order. */
|
|
416
745
|
routes() {
|
|
@@ -485,10 +814,12 @@ class Mesh {
|
|
|
485
814
|
if (this.links.get(url) === "self" || this.stopped) return;
|
|
486
815
|
const { handlers, id } = this.opts;
|
|
487
816
|
const origin = this.origins.get(url);
|
|
817
|
+
const protocols = handlers.meshLocalProtocols();
|
|
488
818
|
const req = {
|
|
489
819
|
from: id,
|
|
490
820
|
peers: handlers.meshLocalPeers(),
|
|
491
|
-
messages: origin ? handlers.meshOutbox(origin) : []
|
|
821
|
+
messages: origin ? handlers.meshOutbox(origin) : [],
|
|
822
|
+
...protocols.length ? { protocols } : {}
|
|
492
823
|
};
|
|
493
824
|
let res;
|
|
494
825
|
try {
|
|
@@ -506,6 +837,7 @@ class Mesh {
|
|
|
506
837
|
const learned = this.origins.get(url) !== res.id;
|
|
507
838
|
this.origins.set(url, res.id);
|
|
508
839
|
handlers.meshApplyPeers(res.id, res.peers ?? []);
|
|
840
|
+
handlers.meshApplyProtocols(res.id, res.protocols ?? []);
|
|
509
841
|
handlers.meshDrop(res.accepted ?? []);
|
|
510
842
|
if (learned && handlers.meshOutbox(res.id).length) this.again = true;
|
|
511
843
|
this.setLink(url, "up");
|
|
@@ -552,6 +884,7 @@ function postJson(url, headers, body, timeoutMs) {
|
|
|
552
884
|
|
|
553
885
|
function createBrokerApp(opts) {
|
|
554
886
|
const routes = opts.handlers.routes();
|
|
887
|
+
const protocolRoutes = opts.handlers.protocolRoutes();
|
|
555
888
|
const router = createRouter();
|
|
556
889
|
router.get(HEALTH_ROUTE, defineEventHandler(() => ({
|
|
557
890
|
status: "ok",
|
|
@@ -572,6 +905,7 @@ function createBrokerApp(opts) {
|
|
|
572
905
|
}));
|
|
573
906
|
for (const route of BROKER_POST_ROUTES) post(route, (body) => routes[route](body));
|
|
574
907
|
post(RELAY_ROUTE, (body) => opts.handlers.relayMessages(body));
|
|
908
|
+
for (const route of PROTOCOL_ROUTES) post(route, (body) => protocolRoutes[route](body));
|
|
575
909
|
if (opts.mesh) post(MESH_SYNC_ROUTE, (body) => opts.handlers.meshSync(body));
|
|
576
910
|
const app = createApp();
|
|
577
911
|
app.use(router);
|
|
@@ -707,7 +1041,8 @@ async function relayUndelivered(state, url, token) {
|
|
|
707
1041
|
to_hostname: peer.hostname,
|
|
708
1042
|
text: m.text,
|
|
709
1043
|
mode: m.mode ?? "pull",
|
|
710
|
-
sent_at: m.sent_at
|
|
1044
|
+
sent_at: m.sent_at,
|
|
1045
|
+
...m.meta ? { meta: m.meta } : {}
|
|
711
1046
|
});
|
|
712
1047
|
}
|
|
713
1048
|
let sent = 0;
|
|
@@ -719,7 +1054,7 @@ async function relayUndelivered(state, url, token) {
|
|
|
719
1054
|
dropped += r.dropped;
|
|
720
1055
|
} else if (res.status === 404) {
|
|
721
1056
|
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 });
|
|
1057
|
+
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
1058
|
if (r.ok && (await r.json()).ok) sent++;
|
|
724
1059
|
else dropped++;
|
|
725
1060
|
}
|
|
@@ -733,7 +1068,7 @@ async function relayUndelivered(state, url, token) {
|
|
|
733
1068
|
return { sent, dropped };
|
|
734
1069
|
}
|
|
735
1070
|
|
|
736
|
-
const version = "0.
|
|
1071
|
+
const version = "0.10.0";
|
|
737
1072
|
const pkg = {
|
|
738
1073
|
version: version};
|
|
739
1074
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unagents/messenger-broker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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.
|
|
31
|
+
"@unagents/messenger-protocol": "0.10.0",
|
|
32
32
|
"consola": "^3.4.2",
|
|
33
33
|
"defu": "^6.1.7",
|
|
34
34
|
"h3": "^1.15.11",
|