@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
|
-
*
|
|
42
|
-
*
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1009
|
+
m = JSON.parse(msg.toString());
|
|
607
1010
|
} catch {
|
|
608
1011
|
return;
|
|
609
1012
|
}
|
|
610
|
-
|
|
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, {
|
|
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
|
|
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.
|
|
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 = () => ({
|
|
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({
|
|
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 :
|
|
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.
|
|
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.
|
|
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",
|