agent-inbox 0.0.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CLAUDE.md +113 -0
  2. package/README.md +195 -1
  3. package/dist/federation/address.d.ts +24 -0
  4. package/dist/federation/address.d.ts.map +1 -0
  5. package/dist/federation/address.js +54 -0
  6. package/dist/federation/address.js.map +1 -0
  7. package/dist/federation/connection-manager.d.ts +118 -0
  8. package/dist/federation/connection-manager.d.ts.map +1 -0
  9. package/dist/federation/connection-manager.js +369 -0
  10. package/dist/federation/connection-manager.js.map +1 -0
  11. package/dist/federation/delivery-queue.d.ts +66 -0
  12. package/dist/federation/delivery-queue.d.ts.map +1 -0
  13. package/dist/federation/delivery-queue.js +199 -0
  14. package/dist/federation/delivery-queue.js.map +1 -0
  15. package/dist/federation/index.d.ts +7 -0
  16. package/dist/federation/index.d.ts.map +1 -0
  17. package/dist/federation/index.js +6 -0
  18. package/dist/federation/index.js.map +1 -0
  19. package/dist/federation/routing-engine.d.ts +74 -0
  20. package/dist/federation/routing-engine.d.ts.map +1 -0
  21. package/dist/federation/routing-engine.js +158 -0
  22. package/dist/federation/routing-engine.js.map +1 -0
  23. package/dist/federation/trust.d.ts +39 -0
  24. package/dist/federation/trust.d.ts.map +1 -0
  25. package/dist/federation/trust.js +64 -0
  26. package/dist/federation/trust.js.map +1 -0
  27. package/dist/index.d.ts +60 -2
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +217 -18
  30. package/dist/index.js.map +1 -1
  31. package/dist/ipc/ipc-server.d.ts +21 -0
  32. package/dist/ipc/ipc-server.d.ts.map +1 -0
  33. package/dist/ipc/ipc-server.js +173 -0
  34. package/dist/ipc/ipc-server.js.map +1 -0
  35. package/dist/jsonrpc/mail-server.d.ts +45 -0
  36. package/dist/jsonrpc/mail-server.d.ts.map +1 -0
  37. package/dist/jsonrpc/mail-server.js +284 -0
  38. package/dist/jsonrpc/mail-server.js.map +1 -0
  39. package/dist/map/map-client.d.ts +91 -0
  40. package/dist/map/map-client.d.ts.map +1 -0
  41. package/dist/map/map-client.js +202 -0
  42. package/dist/map/map-client.js.map +1 -0
  43. package/dist/mcp/mcp-server.d.ts +23 -0
  44. package/dist/mcp/mcp-server.d.ts.map +1 -0
  45. package/dist/mcp/mcp-server.js +226 -0
  46. package/dist/mcp/mcp-server.js.map +1 -0
  47. package/dist/push/notifier.d.ts +49 -0
  48. package/dist/push/notifier.d.ts.map +1 -0
  49. package/dist/push/notifier.js +150 -0
  50. package/dist/push/notifier.js.map +1 -0
  51. package/dist/registry/warm-registry.d.ts +63 -0
  52. package/dist/registry/warm-registry.d.ts.map +1 -0
  53. package/dist/registry/warm-registry.js +173 -0
  54. package/dist/registry/warm-registry.js.map +1 -0
  55. package/dist/router/message-router.d.ts +44 -0
  56. package/dist/router/message-router.d.ts.map +1 -0
  57. package/dist/router/message-router.js +137 -0
  58. package/dist/router/message-router.js.map +1 -0
  59. package/dist/storage/interface.d.ts +31 -0
  60. package/dist/storage/interface.d.ts.map +1 -0
  61. package/dist/storage/interface.js +2 -0
  62. package/dist/storage/interface.js.map +1 -0
  63. package/dist/storage/memory.d.ts +28 -0
  64. package/dist/storage/memory.d.ts.map +1 -0
  65. package/dist/storage/memory.js +118 -0
  66. package/dist/storage/memory.js.map +1 -0
  67. package/dist/storage/sqlite.d.ts +35 -0
  68. package/dist/storage/sqlite.d.ts.map +1 -0
  69. package/dist/storage/sqlite.js +445 -0
  70. package/dist/storage/sqlite.js.map +1 -0
  71. package/dist/traceability/traceability.d.ts +29 -0
  72. package/dist/traceability/traceability.d.ts.map +1 -0
  73. package/dist/traceability/traceability.js +150 -0
  74. package/dist/traceability/traceability.js.map +1 -0
  75. package/dist/types.d.ts +261 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +3 -0
  78. package/dist/types.js.map +1 -0
  79. package/docs/DESIGN.md +1156 -0
  80. package/docs/PLAN.md +545 -0
  81. package/hooks/inbox-hook.mjs +119 -0
  82. package/hooks/register-hook.mjs +69 -0
  83. package/package.json +33 -25
  84. package/rules/agent-inbox.md +78 -0
  85. package/src/federation/address.ts +61 -0
  86. package/src/federation/connection-manager.ts +458 -0
  87. package/src/federation/delivery-queue.ts +222 -0
  88. package/src/federation/index.ts +6 -0
  89. package/src/federation/routing-engine.ts +188 -0
  90. package/src/federation/trust.ts +71 -0
  91. package/src/index.ts +299 -0
  92. package/src/ipc/ipc-server.ts +207 -0
  93. package/src/jsonrpc/mail-server.ts +356 -0
  94. package/src/map/map-client.ts +260 -0
  95. package/src/mcp/mcp-server.ts +272 -0
  96. package/src/push/notifier.ts +192 -0
  97. package/src/registry/warm-registry.ts +210 -0
  98. package/src/router/message-router.ts +175 -0
  99. package/src/storage/interface.ts +48 -0
  100. package/src/storage/memory.ts +145 -0
  101. package/src/storage/sqlite.ts +645 -0
  102. package/src/traceability/traceability.ts +183 -0
  103. package/src/types.ts +297 -0
  104. package/test/federation/address.test.ts +101 -0
  105. package/test/federation/connection-manager.test.ts +546 -0
  106. package/test/federation/delivery-queue.test.ts +159 -0
  107. package/test/federation/integration.test.ts +823 -0
  108. package/test/federation/routing-engine.test.ts +117 -0
  109. package/test/federation/sdk-integration.test.ts +748 -0
  110. package/test/federation/trust.test.ts +89 -0
  111. package/test/ipc-jsonrpc.test.ts +113 -0
  112. package/test/ipc-server.test.ts +197 -0
  113. package/test/mail-server.test.ts +208 -0
  114. package/test/map-client.test.ts +408 -0
  115. package/test/message-router.test.ts +184 -0
  116. package/test/push-notifier.test.ts +139 -0
  117. package/test/registry/warm-registry.test.ts +171 -0
  118. package/test/sqlite-storage.test.ts +243 -0
  119. package/test/storage.test.ts +196 -0
  120. package/test/traceability.test.ts +123 -0
  121. package/tsconfig.json +20 -0
  122. package/tsup.config.ts +10 -0
  123. package/vitest.config.ts +8 -0
  124. package/dist/index.d.mts +0 -2
  125. package/dist/index.mjs +0 -1
  126. package/dist/index.mjs.map +0 -1
@@ -0,0 +1,458 @@
1
+ import { EventEmitter } from "node:events";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import * as crypto from "node:crypto";
6
+ import type {
7
+ FederatedAddress,
8
+ FederationConfig,
9
+ FederationLink,
10
+ FederationPeerConfig,
11
+ Message,
12
+ SystemId,
13
+ } from "../types.js";
14
+ import type {
15
+ MapConnection,
16
+ MapAgentConnectionClass,
17
+ IncomingMapMessage,
18
+ } from "../map/map-client.js";
19
+ import { RoutingEngine } from "./routing-engine.js";
20
+ import { DeliveryQueue } from "./delivery-queue.js";
21
+ import { TrustManager } from "./trust.js";
22
+ import { parseAddress, isRemoteAddress } from "./address.js";
23
+
24
+ export interface DeliveryResult {
25
+ delivered: boolean;
26
+ peerId?: string;
27
+ queued?: boolean;
28
+ error?: string;
29
+ }
30
+
31
+ /**
32
+ * Callback for handling incoming federation messages.
33
+ * Wired to router.routeMessage() by index.ts.
34
+ */
35
+ export type IncomingMessageHandler = (incoming: {
36
+ from: string;
37
+ peerId: string;
38
+ payload: unknown;
39
+ meta?: Record<string, unknown>;
40
+ }) => void;
41
+
42
+ /**
43
+ * Manages MAP connections and federation peer links.
44
+ *
45
+ * Coordinates routing, delivery queuing, and trust enforcement
46
+ * for cross-system messaging. When an SDK connect function is provided,
47
+ * opens real MAP connections to federation peers for actual message transport.
48
+ */
49
+ export class ConnectionManager {
50
+ private peers = new Map<string, FederationLink>();
51
+ private connections = new Map<string, MapConnection>();
52
+ private systemId: SystemId;
53
+ private sdkClass: MapAgentConnectionClass | null;
54
+ private onIncoming: IncomingMessageHandler | null;
55
+ readonly routing: RoutingEngine;
56
+ readonly queue: DeliveryQueue;
57
+ readonly trust: TrustManager;
58
+
59
+ constructor(
60
+ private events: EventEmitter,
61
+ private config: FederationConfig = {},
62
+ opts?: {
63
+ sdkClass?: MapAgentConnectionClass;
64
+ onIncomingMessage?: IncomingMessageHandler;
65
+ }
66
+ ) {
67
+ this.systemId = this.resolveSystemId();
68
+ this.routing = new RoutingEngine(events, config.routing);
69
+ this.queue = new DeliveryQueue(events, config.deliveryQueue);
70
+ this.trust = new TrustManager(config.trust);
71
+ this.sdkClass = opts?.sdkClass ?? null;
72
+ this.onIncoming = opts?.onIncomingMessage ?? null;
73
+
74
+ // Wire up flush-on-reconnect
75
+ this.events.on("federation.connected", (peerId: string) => {
76
+ if (config.deliveryQueue?.flushOnReconnect !== false) {
77
+ const queued = this.queue.flush(peerId);
78
+ if (queued.length > 0) {
79
+ this.events.emit("federation.flushing", {
80
+ peerId,
81
+ count: queued.length,
82
+ });
83
+ for (const entry of queued) {
84
+ this.route(entry.message).catch(() => {
85
+ // Re-queue if delivery still fails
86
+ this.queue.enqueue(peerId, entry.message);
87
+ });
88
+ }
89
+ }
90
+ }
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Get the resolved system ID for this Agent Inbox instance.
96
+ */
97
+ getSystemId(): SystemId {
98
+ return this.systemId;
99
+ }
100
+
101
+ /**
102
+ * Establish federation with a peer. Uses MAP federation/connect protocol.
103
+ * If an SDK class was injected, opens a real MAP connection to the peer.
104
+ * Returns the federation link, or throws if trust check fails.
105
+ */
106
+ async federate(peer: FederationPeerConfig): Promise<FederationLink> {
107
+ // Trust check
108
+ if (!this.trust.canConnect(peer.systemId)) {
109
+ throw new Error(
110
+ `Federation denied: system "${peer.systemId}" not in allowed servers list`
111
+ );
112
+ }
113
+
114
+ // Open real MAP connection if SDK is available
115
+ let conn: MapConnection | undefined;
116
+ if (this.sdkClass) {
117
+ try {
118
+ conn = await this.sdkClass.connect(peer.url, {
119
+ name: this.systemId.id,
120
+ role: "gateway",
121
+ scopes: ["federation"],
122
+ capabilities: { trajectory: { canReport: false } },
123
+ metadata: {
124
+ systemId: this.systemId.id,
125
+ type: "federation-gateway",
126
+ peerSystemId: peer.systemId,
127
+ },
128
+ reconnection: {
129
+ enabled: true,
130
+ maxRetries: 5,
131
+ baseDelayMs: 1000,
132
+ maxDelayMs: 30000,
133
+ },
134
+ });
135
+
136
+ // Register incoming message handler
137
+ conn.onMessage((msg: IncomingMapMessage) => {
138
+ this.handlePeerMessage(peer.systemId, msg);
139
+ });
140
+
141
+ this.connections.set(peer.systemId, conn);
142
+ } catch (err) {
143
+ throw new Error(
144
+ `Federation connection failed for "${peer.systemId}": ${err instanceof Error ? err.message : err}`
145
+ );
146
+ }
147
+ }
148
+
149
+ const link: FederationLink = {
150
+ peerId: peer.systemId,
151
+ sessionId: crypto.randomUUID(),
152
+ status: "connected",
153
+ exposure: peer.exposure ?? { agents: "all" },
154
+ url: peer.url,
155
+ connectedAt: new Date().toISOString(),
156
+ };
157
+
158
+ this.peers.set(peer.systemId, link);
159
+ this.events.emit("federation.connected", peer.systemId);
160
+ return link;
161
+ }
162
+
163
+ /**
164
+ * Disconnect from a federation peer.
165
+ * Closes the real MAP connection if one exists.
166
+ */
167
+ async disconnect(peerId: string): Promise<void> {
168
+ const link = this.peers.get(peerId);
169
+ if (!link) return;
170
+
171
+ // Close real connection
172
+ const conn = this.connections.get(peerId);
173
+ if (conn) {
174
+ try {
175
+ await conn.disconnect();
176
+ } catch {
177
+ // Best-effort disconnect
178
+ }
179
+ this.connections.delete(peerId);
180
+ }
181
+
182
+ link.status = "disconnected";
183
+ this.routing.removePeer(peerId);
184
+ this.peers.delete(peerId);
185
+ this.events.emit("federation.disconnected", peerId);
186
+ }
187
+
188
+ /**
189
+ * Route a message to the correct federation peer.
190
+ * Resolves the address, checks trust, and delivers or queues.
191
+ * If a real connection exists, sends via conn.send(); otherwise emits event.
192
+ */
193
+ async route(message: Message): Promise<DeliveryResult> {
194
+ // Determine target for each recipient
195
+ for (const recipient of message.recipients) {
196
+ const addr = parseAddress(recipient.agent_id);
197
+ if (!isRemoteAddress(addr)) continue;
198
+
199
+ const peerId = this.routing.resolveRoute(addr);
200
+ if (!peerId) {
201
+ // Route unknown — try strategies
202
+ return this.handleUnknownRoute(addr, message);
203
+ }
204
+
205
+ // Check trust
206
+ if (!this.trust.canRoute(peerId, message.scope)) {
207
+ return {
208
+ delivered: false,
209
+ peerId,
210
+ error: `Trust policy denies routing to scope "${message.scope}" from system "${peerId}"`,
211
+ };
212
+ }
213
+
214
+ const link = this.peers.get(peerId);
215
+ if (!link || link.status !== "connected") {
216
+ // TODO: When resolved peerId has no peer link (e.g., system-qualified address
217
+ // targeting a system we're not directly connected to), fall through to
218
+ // handleUnknownRoute so broadcast/hierarchical strategies can attempt delivery
219
+ // via connected peers or upstream hubs instead of immediately queuing.
220
+ this.queue.enqueue(peerId, message);
221
+ return { delivered: false, peerId, queued: true };
222
+ }
223
+
224
+ // Deliver — use real connection if available, otherwise emit event
225
+ const sendResult = await this.sendToPeer(peerId, addr, message);
226
+ if (sendResult) {
227
+ recipient.delivered_at = new Date().toISOString();
228
+ return { delivered: true, peerId };
229
+ }
230
+ // Send failed — queue for retry
231
+ this.queue.enqueue(peerId, message);
232
+ return { delivered: false, peerId, queued: true };
233
+ }
234
+
235
+ return { delivered: false, error: "No remote recipients found" };
236
+ }
237
+
238
+ /**
239
+ * Send a message to a peer via real connection or event emission.
240
+ * Returns true if send succeeded (or event was emitted).
241
+ */
242
+ private async sendToPeer(
243
+ peerId: string,
244
+ addr: FederatedAddress,
245
+ message: Message
246
+ ): Promise<boolean> {
247
+ const conn = this.connections.get(peerId);
248
+ if (conn) {
249
+ // Real transport — send via MAP SDK connection
250
+ try {
251
+ await conn.send(
252
+ { agentId: addr.agent, scope: addr.scope },
253
+ message.content,
254
+ {
255
+ messageId: message.id,
256
+ senderId: message.sender_id,
257
+ sourceSystem: this.systemId.id,
258
+ targetAgent: addr.agent,
259
+ importance: message.importance,
260
+ ...(message.subject ? { subject: message.subject } : {}),
261
+ ...(message.thread_tag ? { threadTag: message.thread_tag } : {}),
262
+ ...(message.in_reply_to ? { inReplyTo: message.in_reply_to } : {}),
263
+ }
264
+ );
265
+ return true;
266
+ } catch {
267
+ return false;
268
+ }
269
+ }
270
+
271
+ // No real connection — emit event (for testing / event-based mode)
272
+ this.events.emit("federation.route", { peerId, message });
273
+ return true;
274
+ }
275
+
276
+ /**
277
+ * Handle an incoming message from a federation peer connection.
278
+ * Delegates to the injected message handler (wired to router.routeMessage).
279
+ */
280
+ private handlePeerMessage(peerId: string, msg: IncomingMapMessage): void {
281
+ this.events.emit("federation.message.received", { peerId, message: msg });
282
+
283
+ if (this.onIncoming) {
284
+ this.onIncoming({
285
+ from: msg.from,
286
+ peerId,
287
+ payload: msg.payload,
288
+ meta: msg.meta,
289
+ });
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Handle routing to an unknown agent. Behavior depends on strategy.
295
+ */
296
+ private async handleUnknownRoute(
297
+ addr: FederatedAddress,
298
+ message: Message
299
+ ): Promise<DeliveryResult> {
300
+ const strategy = this.routing.getStrategy();
301
+
302
+ if (strategy === "broadcast") {
303
+ // Forward to all connected peers
304
+ const connectedPeers = this.getConnectedPeers();
305
+ if (connectedPeers.length === 0) {
306
+ return { delivered: false, error: "No connected peers for broadcast" };
307
+ }
308
+
309
+ // Try real connections first, fall back to event
310
+ for (const peer of connectedPeers) {
311
+ await this.sendToPeer(peer.peerId, addr, message);
312
+ }
313
+
314
+ this.events.emit("federation.broadcast", {
315
+ message,
316
+ peers: connectedPeers.map((p) => p.peerId),
317
+ timeout: this.routing.getBroadcastTimeout(),
318
+ });
319
+ return { delivered: true };
320
+ }
321
+
322
+ if (strategy === "hierarchical") {
323
+ // Delegate to upstream hubs
324
+ const upstream = this.routing.getUpstream();
325
+ for (const hubId of upstream) {
326
+ const link = this.peers.get(hubId);
327
+ if (link?.status === "connected") {
328
+ const sent = await this.sendToPeer(hubId, addr, message);
329
+ if (sent) {
330
+ return { delivered: true, peerId: hubId };
331
+ }
332
+ }
333
+ }
334
+ return { delivered: false, error: "No reachable upstream hubs" };
335
+ }
336
+
337
+ // Table strategy with refreshOnMiss — emit event for transport layer
338
+ if (this.routing.shouldRefreshOnMiss()) {
339
+ this.events.emit("federation.refresh", { address: addr });
340
+ }
341
+
342
+ // Queue for later if we have a system hint
343
+ if (addr.system) {
344
+ this.queue.enqueue(addr.system, message);
345
+ return { delivered: false, peerId: addr.system, queued: true };
346
+ }
347
+
348
+ return {
349
+ delivered: false,
350
+ error: `No route to agent "${addr.agent}"`,
351
+ };
352
+ }
353
+
354
+ /**
355
+ * Get all federation peer links.
356
+ */
357
+ getPeers(): FederationLink[] {
358
+ return Array.from(this.peers.values());
359
+ }
360
+
361
+ /**
362
+ * Get only connected peers.
363
+ */
364
+ getConnectedPeers(): FederationLink[] {
365
+ return Array.from(this.peers.values()).filter(
366
+ (p) => p.status === "connected"
367
+ );
368
+ }
369
+
370
+ /**
371
+ * Get a specific peer link.
372
+ */
373
+ getPeer(peerId: string): FederationLink | undefined {
374
+ return this.peers.get(peerId);
375
+ }
376
+
377
+ /**
378
+ * Check if a peer is connected.
379
+ */
380
+ isConnected(peerId: string): boolean {
381
+ return this.peers.get(peerId)?.status === "connected";
382
+ }
383
+
384
+ /**
385
+ * Check if a real MAP SDK connection exists for a peer.
386
+ */
387
+ hasTransport(peerId: string): boolean {
388
+ return this.connections.has(peerId);
389
+ }
390
+
391
+ /**
392
+ * Clean up all state. Call on shutdown.
393
+ */
394
+ async destroy(): Promise<void> {
395
+ // Close all real connections
396
+ for (const [peerId, conn] of this.connections) {
397
+ try {
398
+ await conn.disconnect();
399
+ } catch {
400
+ // Best-effort
401
+ }
402
+ this.connections.delete(peerId);
403
+ }
404
+
405
+ this.routing.destroy();
406
+ this.queue.destroy();
407
+ this.peers.clear();
408
+ }
409
+
410
+ /**
411
+ * Resolve the system ID using tiered precedence:
412
+ * 1. Explicit config (INBOX_SYSTEM_ID)
413
+ * 2. Auto-generated (persisted to file for stability across restarts)
414
+ *
415
+ * Note: Tier 2 (MAP systemInfo) is handled after MAP connection is established.
416
+ */
417
+ private resolveSystemId(): SystemId {
418
+ // Tier 1: Explicit config
419
+ if (this.config.systemId) {
420
+ return { id: this.config.systemId, source: "config" };
421
+ }
422
+
423
+ // Tier 3: Auto-generated (with persistence)
424
+ const idFile = path.join(
425
+ os.homedir(),
426
+ ".claude",
427
+ "agent-inbox",
428
+ "system-id"
429
+ );
430
+ try {
431
+ const existing = fs.readFileSync(idFile, "utf-8").trim();
432
+ if (existing) {
433
+ return { id: existing, source: "auto" };
434
+ }
435
+ } catch {
436
+ // File doesn't exist — generate new ID
437
+ }
438
+
439
+ const newId = `inbox-${crypto.randomBytes(4).toString("hex")}`;
440
+ try {
441
+ fs.mkdirSync(path.dirname(idFile), { recursive: true });
442
+ fs.writeFileSync(idFile, newId);
443
+ } catch {
444
+ // Best-effort persistence
445
+ }
446
+ return { id: newId, source: "auto" };
447
+ }
448
+
449
+ /**
450
+ * Update system ID from MAP server's systemInfo (Tier 2).
451
+ * Only used if no explicit config was set.
452
+ */
453
+ updateSystemIdFromMap(mapSystemName: string): void {
454
+ if (this.systemId.source === "config") return; // Explicit config takes precedence
455
+ this.systemId = { id: mapSystemName, source: "map" };
456
+ this.events.emit("system.id.updated", this.systemId);
457
+ }
458
+ }
@@ -0,0 +1,222 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { ulid } from "ulid";
3
+ import type { Message, DeliveryQueueConfig, QueuedMessage } from "../types.js";
4
+
5
+ const DEFAULT_CONFIG: DeliveryQueueConfig = {
6
+ persistence: "memory",
7
+ maxTTL: 86_400_000, // 24h
8
+ maxQueueSize: 10_000,
9
+ retryStrategy: "exponential",
10
+ retryBaseInterval: 1_000,
11
+ retryMaxAttempts: 0, // unlimited until TTL
12
+ flushOnReconnect: true,
13
+ overflow: "drop-oldest",
14
+ };
15
+
16
+ /**
17
+ * Delivery queue for messages to offline or unreachable federation peers.
18
+ *
19
+ * Supports configurable TTL, overflow policy, and retry strategy.
20
+ * Currently memory-only; SQLite persistence is a future enhancement.
21
+ */
22
+ export class DeliveryQueue {
23
+ private queues = new Map<string, QueuedMessage[]>();
24
+ private config: DeliveryQueueConfig;
25
+ private tickTimer?: ReturnType<typeof setInterval>;
26
+
27
+ constructor(
28
+ private events: EventEmitter,
29
+ config?: Partial<DeliveryQueueConfig>
30
+ ) {
31
+ this.config = { ...DEFAULT_CONFIG, ...config };
32
+ }
33
+
34
+ /**
35
+ * Enqueue a message for a peer. Returns the queued message ID, or null if rejected.
36
+ */
37
+ enqueue(peerId: string, message: Message): string | null {
38
+ let queue = this.queues.get(peerId);
39
+ if (!queue) {
40
+ queue = [];
41
+ this.queues.set(peerId, queue);
42
+ }
43
+
44
+ // Check queue size limit
45
+ if (queue.length >= this.config.maxQueueSize) {
46
+ switch (this.config.overflow) {
47
+ case "drop-oldest":
48
+ queue.shift();
49
+ break;
50
+ case "drop-newest":
51
+ return null; // Don't enqueue
52
+ case "reject-new":
53
+ return null;
54
+ }
55
+ }
56
+
57
+ const now = new Date().toISOString();
58
+ const entry: QueuedMessage = {
59
+ id: ulid(),
60
+ peerId,
61
+ message,
62
+ enqueuedAt: now,
63
+ attempts: 0,
64
+ nextRetry: now, // Ready to send immediately
65
+ };
66
+
67
+ queue.push(entry);
68
+ this.events.emit("queue.enqueued", { peerId, messageId: entry.id });
69
+ return entry.id;
70
+ }
71
+
72
+ /**
73
+ * Flush all queued messages for a peer (on reconnect).
74
+ * Returns the messages and removes them from the queue.
75
+ */
76
+ flush(peerId: string): QueuedMessage[] {
77
+ const queue = this.queues.get(peerId);
78
+ if (!queue || queue.length === 0) return [];
79
+
80
+ const messages = [...queue];
81
+ this.queues.delete(peerId);
82
+ this.events.emit("queue.flushed", { peerId, count: messages.length });
83
+ return messages;
84
+ }
85
+
86
+ /**
87
+ * Get messages ready for retry. Does not remove them from the queue.
88
+ */
89
+ getRetryable(peerId: string): QueuedMessage[] {
90
+ const queue = this.queues.get(peerId);
91
+ if (!queue) return [];
92
+
93
+ const now = Date.now();
94
+ return queue.filter((entry) => {
95
+ if (!entry.nextRetry) return true;
96
+ return new Date(entry.nextRetry).getTime() <= now;
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Record a retry attempt for a message. Updates attempt count and next retry time.
102
+ * Returns false if max attempts exceeded (message should be dropped).
103
+ */
104
+ recordAttempt(peerId: string, messageId: string): boolean {
105
+ const queue = this.queues.get(peerId);
106
+ if (!queue) return false;
107
+
108
+ const entry = queue.find((e) => e.id === messageId);
109
+ if (!entry) return false;
110
+
111
+ entry.attempts++;
112
+ entry.lastAttempt = new Date().toISOString();
113
+
114
+ // Check max attempts
115
+ if (this.config.retryMaxAttempts > 0 && entry.attempts >= this.config.retryMaxAttempts) {
116
+ this.removeEntry(peerId, messageId);
117
+ return false;
118
+ }
119
+
120
+ // Calculate next retry
121
+ const delay =
122
+ this.config.retryStrategy === "exponential"
123
+ ? this.config.retryBaseInterval * Math.pow(2, entry.attempts - 1)
124
+ : this.config.retryBaseInterval;
125
+ entry.nextRetry = new Date(Date.now() + delay).toISOString();
126
+ return true;
127
+ }
128
+
129
+ /**
130
+ * Remove a specific entry (e.g., after successful delivery).
131
+ */
132
+ removeEntry(peerId: string, messageId: string): boolean {
133
+ const queue = this.queues.get(peerId);
134
+ if (!queue) return false;
135
+
136
+ const idx = queue.findIndex((e) => e.id === messageId);
137
+ if (idx === -1) return false;
138
+
139
+ queue.splice(idx, 1);
140
+ if (queue.length === 0) this.queues.delete(peerId);
141
+ return true;
142
+ }
143
+
144
+ /**
145
+ * Process tick: expire old messages past TTL.
146
+ */
147
+ tick(): number {
148
+ const now = Date.now();
149
+ let expired = 0;
150
+
151
+ for (const [peerId, queue] of this.queues.entries()) {
152
+ const before = queue.length;
153
+ const remaining = queue.filter((entry) => {
154
+ const age = now - new Date(entry.enqueuedAt).getTime();
155
+ return age < this.config.maxTTL;
156
+ });
157
+ expired += before - remaining.length;
158
+
159
+ if (remaining.length === 0) {
160
+ this.queues.delete(peerId);
161
+ } else {
162
+ this.queues.set(peerId, remaining);
163
+ }
164
+ }
165
+
166
+ if (expired > 0) {
167
+ this.events.emit("queue.expired", { count: expired });
168
+ }
169
+ return expired;
170
+ }
171
+
172
+ /**
173
+ * Start periodic tick timer for TTL expiry.
174
+ */
175
+ startTicking(intervalMs: number = 60_000): void {
176
+ this.stopTicking();
177
+ this.tickTimer = setInterval(() => this.tick(), intervalMs);
178
+ }
179
+
180
+ /**
181
+ * Stop periodic tick timer.
182
+ */
183
+ stopTicking(): void {
184
+ if (this.tickTimer) {
185
+ clearInterval(this.tickTimer);
186
+ this.tickTimer = undefined;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Get queue depth for a peer.
192
+ */
193
+ size(peerId: string): number {
194
+ return this.queues.get(peerId)?.length ?? 0;
195
+ }
196
+
197
+ /**
198
+ * Get total queue depth across all peers.
199
+ */
200
+ totalSize(): number {
201
+ let total = 0;
202
+ for (const queue of this.queues.values()) {
203
+ total += queue.length;
204
+ }
205
+ return total;
206
+ }
207
+
208
+ /**
209
+ * List peers with queued messages.
210
+ */
211
+ peers(): string[] {
212
+ return Array.from(this.queues.keys());
213
+ }
214
+
215
+ /**
216
+ * Clean up all state. Call on shutdown.
217
+ */
218
+ destroy(): void {
219
+ this.stopTicking();
220
+ this.queues.clear();
221
+ }
222
+ }
@@ -0,0 +1,6 @@
1
+ export { parseAddress, formatAddress, isRemoteAddress, isBroadcastAddress } from "./address.js";
2
+ export { ConnectionManager } from "./connection-manager.js";
3
+ export type { DeliveryResult } from "./connection-manager.js";
4
+ export { DeliveryQueue } from "./delivery-queue.js";
5
+ export { RoutingEngine } from "./routing-engine.js";
6
+ export { TrustManager } from "./trust.js";