@agentxjs/node-platform 2.0.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/README.md +134 -0
- package/dist/WebSocketConnection-BUL85bFC.d.ts +66 -0
- package/dist/WebSocketFactory-SDWPRZVB.js +8 -0
- package/dist/WebSocketFactory-SDWPRZVB.js.map +1 -0
- package/dist/chunk-BBZV6B5R.js +264 -0
- package/dist/chunk-BBZV6B5R.js.map +1 -0
- package/dist/chunk-DGUM43GV.js +11 -0
- package/dist/chunk-DGUM43GV.js.map +1 -0
- package/dist/chunk-PK2K7CCJ.js +213 -0
- package/dist/chunk-PK2K7CCJ.js.map +1 -0
- package/dist/chunk-TXESAX3X.js +361 -0
- package/dist/chunk-TXESAX3X.js.map +1 -0
- package/dist/chunk-V664KD3R.js +14 -0
- package/dist/chunk-V664KD3R.js.map +1 -0
- package/dist/index.d.ts +140 -0
- package/dist/index.js +223 -0
- package/dist/index.js.map +1 -0
- package/dist/mq/index.d.ts +63 -0
- package/dist/mq/index.js +10 -0
- package/dist/mq/index.js.map +1 -0
- package/dist/network/index.d.ts +17 -0
- package/dist/network/index.js +14 -0
- package/dist/network/index.js.map +1 -0
- package/dist/persistence/index.d.ts +175 -0
- package/dist/persistence/index.js +18 -0
- package/dist/persistence/index.js.map +1 -0
- package/package.json +50 -0
- package/src/bash/NodeBashProvider.ts +54 -0
- package/src/index.ts +151 -0
- package/src/logger/FileLoggerFactory.ts +175 -0
- package/src/logger/index.ts +5 -0
- package/src/mq/OffsetGenerator.ts +48 -0
- package/src/mq/SqliteMessageQueue.ts +240 -0
- package/src/mq/index.ts +30 -0
- package/src/network/WebSocketConnection.ts +206 -0
- package/src/network/WebSocketFactory.ts +17 -0
- package/src/network/WebSocketServer.ts +156 -0
- package/src/network/index.ts +32 -0
- package/src/persistence/Persistence.ts +53 -0
- package/src/persistence/StorageContainerRepository.ts +58 -0
- package/src/persistence/StorageImageRepository.ts +153 -0
- package/src/persistence/StorageSessionRepository.ts +171 -0
- package/src/persistence/index.ts +38 -0
- package/src/persistence/memory.ts +27 -0
- package/src/persistence/sqlite.ts +111 -0
- package/src/persistence/types.ts +32 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SqliteMessageQueue - RxJS-based message queue with SQLite persistence
|
|
3
|
+
*
|
|
4
|
+
* - In-memory pub/sub using RxJS Subject (real-time)
|
|
5
|
+
* - SQLite persistence for recovery guarantee
|
|
6
|
+
* - Consumer offset tracking for at-least-once delivery
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Subject } from "rxjs";
|
|
10
|
+
import { filter } from "rxjs/operators";
|
|
11
|
+
import type { MessageQueue, QueueEntry, QueueConfig, Unsubscribe } from "@agentxjs/core/mq";
|
|
12
|
+
import { createLogger } from "commonxjs/logger";
|
|
13
|
+
import { openDatabase, type Database } from "commonxjs/sqlite";
|
|
14
|
+
import { OffsetGenerator } from "./OffsetGenerator";
|
|
15
|
+
|
|
16
|
+
const logger = createLogger("node-platform/SqliteMessageQueue");
|
|
17
|
+
|
|
18
|
+
interface ResolvedConfig {
|
|
19
|
+
path: string;
|
|
20
|
+
retentionMs: number;
|
|
21
|
+
cleanupIntervalMs: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* SqliteMessageQueue implementation
|
|
26
|
+
*/
|
|
27
|
+
export class SqliteMessageQueue implements MessageQueue {
|
|
28
|
+
private readonly subject = new Subject<QueueEntry>();
|
|
29
|
+
private readonly offsetGen = new OffsetGenerator();
|
|
30
|
+
private readonly config: ResolvedConfig;
|
|
31
|
+
private readonly db: Database;
|
|
32
|
+
private cleanupTimer?: ReturnType<typeof setInterval>;
|
|
33
|
+
private isClosed = false;
|
|
34
|
+
|
|
35
|
+
private constructor(db: Database, config: ResolvedConfig) {
|
|
36
|
+
this.db = db;
|
|
37
|
+
this.config = config;
|
|
38
|
+
|
|
39
|
+
if (this.config.cleanupIntervalMs > 0) {
|
|
40
|
+
this.cleanupTimer = setInterval(() => {
|
|
41
|
+
this.cleanup();
|
|
42
|
+
}, this.config.cleanupIntervalMs);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a new SqliteMessageQueue instance
|
|
48
|
+
*/
|
|
49
|
+
static create(path: string, config?: QueueConfig): SqliteMessageQueue {
|
|
50
|
+
const resolvedConfig: ResolvedConfig = {
|
|
51
|
+
path,
|
|
52
|
+
retentionMs: config?.retentionMs ?? 86400000, // 24 hours
|
|
53
|
+
cleanupIntervalMs: 300000, // 5 minutes
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const db = openDatabase(resolvedConfig.path);
|
|
57
|
+
initializeSchema(db);
|
|
58
|
+
|
|
59
|
+
logger.info("SqliteMessageQueue created", { path: resolvedConfig.path });
|
|
60
|
+
return new SqliteMessageQueue(db, resolvedConfig);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async publish(topic: string, event: unknown): Promise<string> {
|
|
64
|
+
if (this.isClosed) {
|
|
65
|
+
logger.warn("Attempted to publish to closed queue", { topic });
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const offset = this.offsetGen.generate();
|
|
70
|
+
const timestamp = Date.now();
|
|
71
|
+
|
|
72
|
+
const entry: QueueEntry = {
|
|
73
|
+
offset,
|
|
74
|
+
topic,
|
|
75
|
+
event,
|
|
76
|
+
timestamp,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// 1. Persist to SQLite (sync, fast)
|
|
80
|
+
try {
|
|
81
|
+
const eventJson = JSON.stringify(entry.event);
|
|
82
|
+
this.db
|
|
83
|
+
.prepare("INSERT INTO queue_entries (offset, topic, event, timestamp) VALUES (?, ?, ?, ?)")
|
|
84
|
+
.run(entry.offset, entry.topic, eventJson, entry.timestamp);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
logger.error("Failed to persist entry", {
|
|
87
|
+
offset: entry.offset,
|
|
88
|
+
topic: entry.topic,
|
|
89
|
+
error: (err as Error).message,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 2. Broadcast to subscribers (in-memory)
|
|
94
|
+
this.subject.next(entry);
|
|
95
|
+
|
|
96
|
+
return offset;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
subscribe(topic: string, handler: (entry: QueueEntry) => void): Unsubscribe {
|
|
100
|
+
const subscription = this.subject.pipe(filter((entry) => entry.topic === topic)).subscribe({
|
|
101
|
+
next: (entry) => {
|
|
102
|
+
try {
|
|
103
|
+
handler(entry);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
logger.error("Subscriber handler error", {
|
|
106
|
+
topic,
|
|
107
|
+
offset: entry.offset,
|
|
108
|
+
error: (err as Error).message,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
logger.debug("Subscribed to topic", { topic });
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
subscription.unsubscribe();
|
|
118
|
+
logger.debug("Unsubscribed from topic", { topic });
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async ack(consumerId: string, topic: string, offset: string): Promise<void> {
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
|
|
125
|
+
// Check if consumer exists
|
|
126
|
+
const existing = this.db
|
|
127
|
+
.prepare("SELECT 1 FROM queue_consumers WHERE consumer_id = ? AND topic = ?")
|
|
128
|
+
.get(consumerId, topic);
|
|
129
|
+
|
|
130
|
+
if (existing) {
|
|
131
|
+
this.db
|
|
132
|
+
.prepare(
|
|
133
|
+
"UPDATE queue_consumers SET offset = ?, updated_at = ? WHERE consumer_id = ? AND topic = ?"
|
|
134
|
+
)
|
|
135
|
+
.run(offset, now, consumerId, topic);
|
|
136
|
+
} else {
|
|
137
|
+
this.db
|
|
138
|
+
.prepare(
|
|
139
|
+
"INSERT INTO queue_consumers (consumer_id, topic, offset, updated_at) VALUES (?, ?, ?, ?)"
|
|
140
|
+
)
|
|
141
|
+
.run(consumerId, topic, offset, now);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
logger.debug("Consumer acknowledged", { consumerId, topic, offset });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async getOffset(consumerId: string, topic: string): Promise<string | null> {
|
|
148
|
+
const row = this.db
|
|
149
|
+
.prepare("SELECT offset FROM queue_consumers WHERE consumer_id = ? AND topic = ?")
|
|
150
|
+
.get(consumerId, topic) as { offset: string } | undefined;
|
|
151
|
+
|
|
152
|
+
return row?.offset ?? null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async recover(topic: string, afterOffset?: string, limit: number = 1000): Promise<QueueEntry[]> {
|
|
156
|
+
let rows: { offset: string; topic: string; event: string; timestamp: number }[];
|
|
157
|
+
|
|
158
|
+
if (afterOffset) {
|
|
159
|
+
rows = this.db
|
|
160
|
+
.prepare(
|
|
161
|
+
"SELECT offset, topic, event, timestamp FROM queue_entries WHERE topic = ? AND offset > ? ORDER BY offset ASC LIMIT ?"
|
|
162
|
+
)
|
|
163
|
+
.all(topic, afterOffset, limit) as typeof rows;
|
|
164
|
+
} else {
|
|
165
|
+
rows = this.db
|
|
166
|
+
.prepare(
|
|
167
|
+
"SELECT offset, topic, event, timestamp FROM queue_entries WHERE topic = ? ORDER BY offset ASC LIMIT ?"
|
|
168
|
+
)
|
|
169
|
+
.all(topic, limit) as typeof rows;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return rows.map((row) => ({
|
|
173
|
+
offset: row.offset,
|
|
174
|
+
topic: row.topic,
|
|
175
|
+
event: JSON.parse(row.event),
|
|
176
|
+
timestamp: row.timestamp,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async close(): Promise<void> {
|
|
181
|
+
if (this.isClosed) return;
|
|
182
|
+
this.isClosed = true;
|
|
183
|
+
|
|
184
|
+
if (this.cleanupTimer) {
|
|
185
|
+
clearInterval(this.cleanupTimer);
|
|
186
|
+
}
|
|
187
|
+
this.subject.complete();
|
|
188
|
+
this.db.close();
|
|
189
|
+
logger.info("SqliteMessageQueue closed");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Cleanup old entries based on retention policy
|
|
194
|
+
*/
|
|
195
|
+
private cleanup(): void {
|
|
196
|
+
try {
|
|
197
|
+
const cutoff = Date.now() - this.config.retentionMs;
|
|
198
|
+
const result = this.db.prepare("DELETE FROM queue_entries WHERE timestamp < ?").run(cutoff);
|
|
199
|
+
|
|
200
|
+
if (result.changes > 0) {
|
|
201
|
+
logger.debug("Cleaned up old entries", {
|
|
202
|
+
count: result.changes,
|
|
203
|
+
retentionMs: this.config.retentionMs,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
logger.error("Cleanup failed", { error: (err as Error).message });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Initialize database schema
|
|
214
|
+
*/
|
|
215
|
+
function initializeSchema(db: Database): void {
|
|
216
|
+
db.exec(`
|
|
217
|
+
PRAGMA journal_mode=WAL;
|
|
218
|
+
|
|
219
|
+
CREATE TABLE IF NOT EXISTS queue_entries (
|
|
220
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
221
|
+
offset TEXT NOT NULL UNIQUE,
|
|
222
|
+
topic TEXT NOT NULL,
|
|
223
|
+
event TEXT NOT NULL,
|
|
224
|
+
timestamp INTEGER NOT NULL
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
CREATE INDEX IF NOT EXISTS idx_queue_topic_offset ON queue_entries(topic, offset);
|
|
228
|
+
CREATE INDEX IF NOT EXISTS idx_queue_timestamp ON queue_entries(timestamp);
|
|
229
|
+
|
|
230
|
+
CREATE TABLE IF NOT EXISTS queue_consumers (
|
|
231
|
+
consumer_id TEXT NOT NULL,
|
|
232
|
+
topic TEXT NOT NULL,
|
|
233
|
+
offset TEXT NOT NULL,
|
|
234
|
+
updated_at INTEGER NOT NULL,
|
|
235
|
+
PRIMARY KEY (consumer_id, topic)
|
|
236
|
+
);
|
|
237
|
+
`);
|
|
238
|
+
|
|
239
|
+
logger.debug("Queue database schema initialized");
|
|
240
|
+
}
|
package/src/mq/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageQueue Module
|
|
3
|
+
*
|
|
4
|
+
* SQLite-based message queue for Node.js.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { SqliteMessageQueue } from "@agentxjs/node-platform/mq";
|
|
9
|
+
*
|
|
10
|
+
* const queue = SqliteMessageQueue.create("./data/queue.db");
|
|
11
|
+
*
|
|
12
|
+
* // Subscribe to topic
|
|
13
|
+
* queue.subscribe("session-123", (entry) => {
|
|
14
|
+
* console.log(entry.event);
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* // Publish event
|
|
18
|
+
* const offset = await queue.publish("session-123", { type: "message", data: "hello" });
|
|
19
|
+
*
|
|
20
|
+
* // ACK after processing
|
|
21
|
+
* await queue.ack("connection-1", "session-123", offset);
|
|
22
|
+
*
|
|
23
|
+
* // Recover missed events
|
|
24
|
+
* const lastOffset = await queue.getOffset("connection-1", "session-123");
|
|
25
|
+
* const missed = await queue.recover("session-123", lastOffset);
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export { SqliteMessageQueue } from "./SqliteMessageQueue";
|
|
30
|
+
export { OffsetGenerator } from "./OffsetGenerator";
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Connection - Server-side connection wrapper
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Heartbeat (ping/pong)
|
|
6
|
+
* - Reliable message delivery with ACK
|
|
7
|
+
* - Message routing
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { WebSocket as WS } from "ws";
|
|
11
|
+
import type {
|
|
12
|
+
ChannelConnection,
|
|
13
|
+
ChannelServerOptions,
|
|
14
|
+
Unsubscribe,
|
|
15
|
+
SendReliableOptions,
|
|
16
|
+
} from "@agentxjs/core/network";
|
|
17
|
+
import { isAckMessage, type ReliableWrapper } from "@agentxjs/core/network";
|
|
18
|
+
import { createLogger } from "commonxjs/logger";
|
|
19
|
+
|
|
20
|
+
const logger = createLogger("node-platform/WebSocketConnection");
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* WebSocket connection implementation
|
|
24
|
+
*/
|
|
25
|
+
export class WebSocketConnection implements ChannelConnection {
|
|
26
|
+
public readonly id: string;
|
|
27
|
+
private ws: WS;
|
|
28
|
+
private messageHandlers = new Set<(message: string) => void>();
|
|
29
|
+
private closeHandlers = new Set<() => void>();
|
|
30
|
+
private errorHandlers = new Set<(error: Error) => void>();
|
|
31
|
+
private heartbeatInterval?: ReturnType<typeof setInterval>;
|
|
32
|
+
private isAlive = true;
|
|
33
|
+
private pendingAcks = new Map<
|
|
34
|
+
string,
|
|
35
|
+
{ resolve: () => void; timer: ReturnType<typeof setTimeout> }
|
|
36
|
+
>();
|
|
37
|
+
private msgIdCounter = 0;
|
|
38
|
+
|
|
39
|
+
constructor(ws: WS, options: ChannelServerOptions) {
|
|
40
|
+
this.ws = ws;
|
|
41
|
+
this.id = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
|
42
|
+
|
|
43
|
+
this.setupHeartbeat(options);
|
|
44
|
+
this.setupMessageHandler();
|
|
45
|
+
this.setupCloseHandler();
|
|
46
|
+
this.setupErrorHandler();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private setupHeartbeat(options: ChannelServerOptions): void {
|
|
50
|
+
if (options.heartbeat === false) return;
|
|
51
|
+
|
|
52
|
+
const interval = options.heartbeatInterval || 30000;
|
|
53
|
+
|
|
54
|
+
this.ws.on("pong", () => {
|
|
55
|
+
this.isAlive = true;
|
|
56
|
+
logger.debug("Heartbeat pong received", { id: this.id });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
this.heartbeatInterval = setInterval(() => {
|
|
60
|
+
if (!this.isAlive) {
|
|
61
|
+
logger.warn("Client heartbeat timeout, terminating connection", { id: this.id });
|
|
62
|
+
clearInterval(this.heartbeatInterval);
|
|
63
|
+
this.ws.terminate();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.isAlive = false;
|
|
67
|
+
this.ws.ping();
|
|
68
|
+
logger.debug("Heartbeat ping sent", { id: this.id });
|
|
69
|
+
}, interval);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private setupMessageHandler(): void {
|
|
73
|
+
this.ws.on("message", (data: Buffer) => {
|
|
74
|
+
const message = data.toString();
|
|
75
|
+
|
|
76
|
+
// Try to parse as JSON to check for ACK messages
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(message);
|
|
79
|
+
|
|
80
|
+
// Handle ACK response from client
|
|
81
|
+
if (isAckMessage(parsed)) {
|
|
82
|
+
const pending = this.pendingAcks.get(parsed.__ack);
|
|
83
|
+
if (pending) {
|
|
84
|
+
clearTimeout(pending.timer);
|
|
85
|
+
pending.resolve();
|
|
86
|
+
this.pendingAcks.delete(parsed.__ack);
|
|
87
|
+
logger.debug("ACK received", { msgId: parsed.__ack, connectionId: this.id });
|
|
88
|
+
}
|
|
89
|
+
return; // Don't pass ACK messages to application layer
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Not JSON, pass through as normal message
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Pass message to handlers
|
|
96
|
+
for (const handler of this.messageHandlers) {
|
|
97
|
+
handler(message);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private setupCloseHandler(): void {
|
|
103
|
+
this.ws.on("close", () => {
|
|
104
|
+
if (this.heartbeatInterval) {
|
|
105
|
+
clearInterval(this.heartbeatInterval);
|
|
106
|
+
}
|
|
107
|
+
for (const handler of this.closeHandlers) {
|
|
108
|
+
handler();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private setupErrorHandler(): void {
|
|
114
|
+
this.ws.on("error", (err: Error) => {
|
|
115
|
+
if (this.heartbeatInterval) {
|
|
116
|
+
clearInterval(this.heartbeatInterval);
|
|
117
|
+
}
|
|
118
|
+
for (const handler of this.errorHandlers) {
|
|
119
|
+
handler(err);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
send(message: string): void {
|
|
125
|
+
if (this.ws.readyState === 1) {
|
|
126
|
+
// WebSocket.OPEN
|
|
127
|
+
this.ws.send(message);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
sendReliable(message: string, options?: SendReliableOptions): void {
|
|
132
|
+
if (this.ws.readyState !== 1) {
|
|
133
|
+
// WebSocket not open, trigger timeout immediately if callback provided
|
|
134
|
+
options?.onTimeout?.();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// If no ACK callback needed, just send normally
|
|
139
|
+
if (!options?.onAck) {
|
|
140
|
+
this.send(message);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Generate unique message ID
|
|
145
|
+
const msgId = `${this.id}_${++this.msgIdCounter}`;
|
|
146
|
+
|
|
147
|
+
// Wrap message with msgId
|
|
148
|
+
const wrapped: ReliableWrapper = {
|
|
149
|
+
__msgId: msgId,
|
|
150
|
+
__payload: message,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Set up timeout
|
|
154
|
+
const timeout = options.timeout ?? 5000;
|
|
155
|
+
const timer = setTimeout(() => {
|
|
156
|
+
if (this.pendingAcks.has(msgId)) {
|
|
157
|
+
this.pendingAcks.delete(msgId);
|
|
158
|
+
logger.warn("ACK timeout", { msgId, connectionId: this.id, timeout });
|
|
159
|
+
options.onTimeout?.();
|
|
160
|
+
}
|
|
161
|
+
}, timeout);
|
|
162
|
+
|
|
163
|
+
// Store pending ACK
|
|
164
|
+
this.pendingAcks.set(msgId, {
|
|
165
|
+
resolve: options.onAck,
|
|
166
|
+
timer,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Send wrapped message
|
|
170
|
+
this.ws.send(JSON.stringify(wrapped));
|
|
171
|
+
logger.debug("Sent reliable message", { msgId, connectionId: this.id });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
onMessage(handler: (message: string) => void): Unsubscribe {
|
|
175
|
+
this.messageHandlers.add(handler);
|
|
176
|
+
return () => {
|
|
177
|
+
this.messageHandlers.delete(handler);
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
onClose(handler: () => void): Unsubscribe {
|
|
182
|
+
this.closeHandlers.add(handler);
|
|
183
|
+
return () => {
|
|
184
|
+
this.closeHandlers.delete(handler);
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
onError(handler: (error: Error) => void): Unsubscribe {
|
|
189
|
+
this.errorHandlers.add(handler);
|
|
190
|
+
return () => {
|
|
191
|
+
this.errorHandlers.delete(handler);
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
close(): void {
|
|
196
|
+
if (this.heartbeatInterval) {
|
|
197
|
+
clearInterval(this.heartbeatInterval);
|
|
198
|
+
}
|
|
199
|
+
// Clean up pending ACKs
|
|
200
|
+
for (const pending of this.pendingAcks.values()) {
|
|
201
|
+
clearTimeout(pending.timer);
|
|
202
|
+
}
|
|
203
|
+
this.pendingAcks.clear();
|
|
204
|
+
this.ws.close();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js WebSocket factory using the ws library
|
|
3
|
+
*
|
|
4
|
+
* Provides WebSocketFactory implementation for @agentxjs/core RpcClient.
|
|
5
|
+
* Browser environments use native WebSocket (the default in RpcClient).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { WebSocketFactory } from "@agentxjs/core/network";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a WebSocket instance using the ws library (Node.js)
|
|
12
|
+
*/
|
|
13
|
+
export const createNodeWebSocket: WebSocketFactory = (url: string): WebSocket => {
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
15
|
+
const { default: WS } = require("ws");
|
|
16
|
+
return new WS(url) as unknown as WebSocket;
|
|
17
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Server - Manages WebSocket connections
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - Standalone mode (listen on port)
|
|
6
|
+
* - Attached mode (attach to existing HTTP server)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { WebSocket as WS, WebSocketServer as WSS } from "ws";
|
|
10
|
+
import type {
|
|
11
|
+
ChannelServer,
|
|
12
|
+
ChannelConnection,
|
|
13
|
+
ChannelServerOptions,
|
|
14
|
+
MinimalHTTPServer,
|
|
15
|
+
Unsubscribe,
|
|
16
|
+
} from "@agentxjs/core/network";
|
|
17
|
+
import { createLogger } from "commonxjs/logger";
|
|
18
|
+
import { WebSocketConnection } from "./WebSocketConnection";
|
|
19
|
+
|
|
20
|
+
const logger = createLogger("node-platform/WebSocketServer");
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* WebSocket Server
|
|
24
|
+
*/
|
|
25
|
+
export class WebSocketServer implements ChannelServer {
|
|
26
|
+
private wss: WSS | null = null;
|
|
27
|
+
private connections = new Set<WebSocketConnection>();
|
|
28
|
+
private connectionHandlers = new Set<(connection: ChannelConnection) => void>();
|
|
29
|
+
private options: ChannelServerOptions;
|
|
30
|
+
private attachedToServer = false;
|
|
31
|
+
|
|
32
|
+
constructor(options: ChannelServerOptions = {}) {
|
|
33
|
+
this.options = options;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async listen(port: number, host: string = "0.0.0.0"): Promise<void> {
|
|
37
|
+
if (this.wss) {
|
|
38
|
+
throw new Error("Server already listening");
|
|
39
|
+
}
|
|
40
|
+
if (this.attachedToServer) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"Cannot listen when attached to existing server. The server should call listen() instead."
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { WebSocketServer: WSS } = await import("ws");
|
|
47
|
+
this.wss = new WSS({ port, host });
|
|
48
|
+
|
|
49
|
+
this.wss.on("connection", (ws: WS) => {
|
|
50
|
+
this.handleConnection(ws);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
logger.info("WebSocket server listening", { port, host });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
attach(server: MinimalHTTPServer, path: string = "/ws"): void {
|
|
57
|
+
if (this.wss) {
|
|
58
|
+
throw new Error("Server already initialized");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
import("ws").then(({ WebSocketServer: WSS }) => {
|
|
62
|
+
this.wss = new WSS({ noServer: true });
|
|
63
|
+
|
|
64
|
+
// Handle WebSocket upgrade on the HTTP server
|
|
65
|
+
server.on("upgrade", (request, socket, head) => {
|
|
66
|
+
const url = new URL(
|
|
67
|
+
(request as { url?: string }).url || "",
|
|
68
|
+
`http://${(request as { headers: { host?: string } }).headers.host}`
|
|
69
|
+
);
|
|
70
|
+
if (url.pathname === path) {
|
|
71
|
+
this.wss!.handleUpgrade(request as never, socket as never, head as never, (ws: WS) => {
|
|
72
|
+
this.wss!.emit("connection", ws, request);
|
|
73
|
+
});
|
|
74
|
+
} else {
|
|
75
|
+
(socket as { destroy: () => void }).destroy();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
this.wss.on("connection", (ws: WS) => {
|
|
80
|
+
this.handleConnection(ws);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
this.attachedToServer = true;
|
|
84
|
+
logger.info("WebSocket attached to existing HTTP server", { path });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private handleConnection(ws: WS): void {
|
|
89
|
+
const connection = new WebSocketConnection(ws, this.options);
|
|
90
|
+
this.connections.add(connection);
|
|
91
|
+
|
|
92
|
+
logger.info("Client connected", {
|
|
93
|
+
connectionId: connection.id,
|
|
94
|
+
totalConnections: this.connections.size,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
connection.onClose(() => {
|
|
98
|
+
this.connections.delete(connection);
|
|
99
|
+
|
|
100
|
+
logger.info("Client disconnected", {
|
|
101
|
+
connectionId: connection.id,
|
|
102
|
+
totalConnections: this.connections.size,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Notify handlers
|
|
107
|
+
for (const handler of this.connectionHandlers) {
|
|
108
|
+
handler(connection);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onConnection(handler: (connection: ChannelConnection) => void): Unsubscribe {
|
|
113
|
+
this.connectionHandlers.add(handler);
|
|
114
|
+
return () => {
|
|
115
|
+
this.connectionHandlers.delete(handler);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
broadcast(message: string): void {
|
|
120
|
+
for (const connection of this.connections) {
|
|
121
|
+
connection.send(message);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async close(): Promise<void> {
|
|
126
|
+
if (!this.wss) return;
|
|
127
|
+
|
|
128
|
+
for (const connection of this.connections) {
|
|
129
|
+
connection.close();
|
|
130
|
+
}
|
|
131
|
+
this.connections.clear();
|
|
132
|
+
|
|
133
|
+
// Don't close the server if attached to existing HTTP server
|
|
134
|
+
if (!this.attachedToServer) {
|
|
135
|
+
await new Promise<void>((resolve) => {
|
|
136
|
+
// Add timeout to prevent hanging
|
|
137
|
+
const timeout = setTimeout(() => {
|
|
138
|
+
logger.warn("WebSocket server close timeout, forcing close");
|
|
139
|
+
resolve();
|
|
140
|
+
}, 1000);
|
|
141
|
+
|
|
142
|
+
this.wss!.close(() => {
|
|
143
|
+
clearTimeout(timeout);
|
|
144
|
+
resolve();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
this.wss = null;
|
|
149
|
+
logger.info("WebSocket server closed");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async dispose(): Promise<void> {
|
|
153
|
+
await this.close();
|
|
154
|
+
this.connectionHandlers.clear();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network Module
|
|
3
|
+
*
|
|
4
|
+
* WebSocket server and connection for Node.js.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { WebSocketServer } from "@agentxjs/node-platform/network";
|
|
9
|
+
*
|
|
10
|
+
* const server = new WebSocketServer();
|
|
11
|
+
* await server.listen(5200);
|
|
12
|
+
*
|
|
13
|
+
* server.onConnection((connection) => {
|
|
14
|
+
* console.log("Client connected:", connection.id);
|
|
15
|
+
*
|
|
16
|
+
* connection.onMessage((message) => {
|
|
17
|
+
* console.log("Received:", message);
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* // Reliable delivery with ACK
|
|
21
|
+
* connection.sendReliable(JSON.stringify({ type: "event" }), {
|
|
22
|
+
* onAck: () => console.log("Client confirmed receipt"),
|
|
23
|
+
* timeout: 5000,
|
|
24
|
+
* onTimeout: () => console.log("Client did not ACK"),
|
|
25
|
+
* });
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export { WebSocketServer } from "./WebSocketServer";
|
|
31
|
+
export { WebSocketConnection } from "./WebSocketConnection";
|
|
32
|
+
export { createNodeWebSocket } from "./WebSocketFactory";
|