@marktoflow/core 2.0.4 → 2.0.5
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/built-in-operations.d.ts.map +1 -1
- package/dist/built-in-operations.js +5 -1
- package/dist/built-in-operations.js.map +1 -1
- package/dist/engine.d.ts +7 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +134 -12
- package/dist/engine.js.map +1 -1
- package/dist/event-operations.d.ts +59 -0
- package/dist/event-operations.d.ts.map +1 -0
- package/dist/event-operations.js +99 -0
- package/dist/event-operations.js.map +1 -0
- package/dist/event-source.d.ts +195 -0
- package/dist/event-source.d.ts.map +1 -0
- package/dist/event-source.js +757 -0
- package/dist/event-source.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/models.d.ts +87 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +28 -0
- package/dist/models.js.map +1 -1
- package/dist/parallel.d.ts.map +1 -1
- package/dist/parallel.js +6 -2
- package/dist/parallel.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +21 -4
- package/dist/parser.js.map +1 -1
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +17 -2
- package/dist/permissions.js.map +1 -1
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +10 -15
- package/dist/template-engine.js.map +1 -1
- package/dist/utils/errors.d.ts +44 -0
- package/dist/utils/errors.d.ts.map +1 -1
- package/dist/utils/errors.js +151 -0
- package/dist/utils/errors.js.map +1 -1
- package/package.json +5 -3
- package/dist/templates.d.ts +0 -80
- package/dist/templates.d.ts.map +0 -1
- package/dist/templates.js +0 -248
- package/dist/templates.js.map +0 -1
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event sources for event-driven workflows.
|
|
3
|
+
*
|
|
4
|
+
* Provides persistent connections to external services (Discord, Slack, WebSocket, etc.)
|
|
5
|
+
* that emit events to trigger workflow steps or restart workflows.
|
|
6
|
+
*
|
|
7
|
+
* Event sources:
|
|
8
|
+
* - websocket: Connect to any WebSocket endpoint
|
|
9
|
+
* - discord: Listen for Discord events via bot gateway
|
|
10
|
+
* - slack: Listen for Slack events via Socket Mode
|
|
11
|
+
* - cron: Emit events on a schedule (wraps the existing scheduler)
|
|
12
|
+
* - http-stream: SSE (Server-Sent Events) listener
|
|
13
|
+
* - rss: Poll RSS/Atom feeds for new items
|
|
14
|
+
*/
|
|
15
|
+
import { EventEmitter } from "node:events";
|
|
16
|
+
import WebSocket from "ws";
|
|
17
|
+
import { parseDuration } from "./utils/duration.js";
|
|
18
|
+
// ── Abstract Base ────────────────────────────────────────────────────────────
|
|
19
|
+
export class BaseEventSource extends EventEmitter {
|
|
20
|
+
id;
|
|
21
|
+
kind;
|
|
22
|
+
config;
|
|
23
|
+
_status = "disconnected";
|
|
24
|
+
_eventsReceived = 0;
|
|
25
|
+
_lastEventAt;
|
|
26
|
+
_connectedAt;
|
|
27
|
+
_reconnectAttempts = 0;
|
|
28
|
+
_reconnectTimer;
|
|
29
|
+
constructor(config) {
|
|
30
|
+
super();
|
|
31
|
+
this.id = config.id;
|
|
32
|
+
this.kind = config.kind;
|
|
33
|
+
this.config = config;
|
|
34
|
+
}
|
|
35
|
+
get status() {
|
|
36
|
+
return this._status;
|
|
37
|
+
}
|
|
38
|
+
get stats() {
|
|
39
|
+
return {
|
|
40
|
+
id: this.id,
|
|
41
|
+
kind: this.kind,
|
|
42
|
+
status: this._status,
|
|
43
|
+
eventsReceived: this._eventsReceived,
|
|
44
|
+
lastEventAt: this._lastEventAt,
|
|
45
|
+
connectedAt: this._connectedAt,
|
|
46
|
+
reconnectAttempts: this._reconnectAttempts,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Emit an event through the source */
|
|
50
|
+
emitEvent(type, data, raw) {
|
|
51
|
+
if (this.config.filter && !this.config.filter.includes(type)) {
|
|
52
|
+
return; // filtered out
|
|
53
|
+
}
|
|
54
|
+
this._eventsReceived++;
|
|
55
|
+
this._lastEventAt = new Date().toISOString();
|
|
56
|
+
const event = {
|
|
57
|
+
source: this.id,
|
|
58
|
+
type,
|
|
59
|
+
data,
|
|
60
|
+
timestamp: this._lastEventAt,
|
|
61
|
+
raw,
|
|
62
|
+
};
|
|
63
|
+
this.emit("event", event);
|
|
64
|
+
}
|
|
65
|
+
/** Handle disconnection with optional reconnect */
|
|
66
|
+
handleDisconnect(reason) {
|
|
67
|
+
// Don't reconnect if explicitly stopped
|
|
68
|
+
if (this._status === "stopped") {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this._status = "disconnected";
|
|
72
|
+
this._connectedAt = undefined;
|
|
73
|
+
this.emit("disconnected", { source: this.id, reason });
|
|
74
|
+
if (this.config.reconnect !== false) {
|
|
75
|
+
const maxAttempts = this.config.maxReconnectAttempts ?? Infinity;
|
|
76
|
+
if (this._reconnectAttempts < maxAttempts) {
|
|
77
|
+
const delay = this.config.reconnectDelay ?? 5000;
|
|
78
|
+
this._reconnectTimer = setTimeout(() => {
|
|
79
|
+
// Re-check status in case stop() was called during the delay
|
|
80
|
+
if (this._status === "stopped")
|
|
81
|
+
return;
|
|
82
|
+
this._reconnectAttempts++;
|
|
83
|
+
this.connect().catch((err) => {
|
|
84
|
+
this.emit("error", err);
|
|
85
|
+
});
|
|
86
|
+
}, delay);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Stop the source (no reconnect) */
|
|
91
|
+
async stop() {
|
|
92
|
+
this._status = "stopped";
|
|
93
|
+
if (this._reconnectTimer) {
|
|
94
|
+
clearTimeout(this._reconnectTimer);
|
|
95
|
+
this._reconnectTimer = undefined;
|
|
96
|
+
}
|
|
97
|
+
await this.disconnect();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// ── WebSocket Source ──────────────────────────────────────────────────────────
|
|
101
|
+
export class WebSocketEventSource extends BaseEventSource {
|
|
102
|
+
ws;
|
|
103
|
+
constructor(config) {
|
|
104
|
+
super({ ...config, kind: "websocket" });
|
|
105
|
+
}
|
|
106
|
+
async connect() {
|
|
107
|
+
const url = this.config.options.url;
|
|
108
|
+
if (!url)
|
|
109
|
+
throw new Error("WebSocket event source requires 'url' option");
|
|
110
|
+
this._status = "connecting";
|
|
111
|
+
this.emit("connecting", { source: this.id });
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const headers = this.config.options.headers ?? {};
|
|
114
|
+
this.ws = new WebSocket(url, { headers });
|
|
115
|
+
this.ws.on("open", () => {
|
|
116
|
+
this._status = "connected";
|
|
117
|
+
this._connectedAt = new Date().toISOString();
|
|
118
|
+
this._reconnectAttempts = 0;
|
|
119
|
+
this.emit("connected", { source: this.id });
|
|
120
|
+
resolve();
|
|
121
|
+
});
|
|
122
|
+
this.ws.on("message", (data) => {
|
|
123
|
+
const raw = data.toString();
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(raw);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
parsed = { message: raw };
|
|
130
|
+
}
|
|
131
|
+
const type = parsed.type ?? parsed.event ?? "message";
|
|
132
|
+
this.emitEvent(type, parsed, raw);
|
|
133
|
+
});
|
|
134
|
+
this.ws.on("close", (code, reason) => {
|
|
135
|
+
this.handleDisconnect(`code=${code} reason=${reason.toString()}`);
|
|
136
|
+
});
|
|
137
|
+
this.ws.on("error", (err) => {
|
|
138
|
+
if (this._status === "connecting") {
|
|
139
|
+
reject(err);
|
|
140
|
+
}
|
|
141
|
+
this._status = "error";
|
|
142
|
+
this.emit("error", err);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async disconnect() {
|
|
147
|
+
if (this.ws) {
|
|
148
|
+
this.ws.removeAllListeners();
|
|
149
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
150
|
+
this.ws.close();
|
|
151
|
+
}
|
|
152
|
+
this.ws = undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Send a message through the WebSocket */
|
|
156
|
+
send(data) {
|
|
157
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
158
|
+
throw new Error(`WebSocket source '${this.id}' is not connected`);
|
|
159
|
+
}
|
|
160
|
+
const payload = typeof data === "string" ? data : JSON.stringify(data);
|
|
161
|
+
this.ws.send(payload);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// ── Discord Source ───────────────────────────────────────────────────────────
|
|
165
|
+
/**
|
|
166
|
+
* Discord event source via the Discord Gateway (WebSocket).
|
|
167
|
+
* Uses the Discord Bot Gateway API directly — no external library needed.
|
|
168
|
+
*
|
|
169
|
+
* Required options:
|
|
170
|
+
* - token: Discord bot token
|
|
171
|
+
* - intents: Gateway intents bitmask (e.g., 513 for GUILDS + GUILD_MESSAGES)
|
|
172
|
+
*
|
|
173
|
+
* Optional:
|
|
174
|
+
* - filter: Array of event types to listen for (e.g., ["MESSAGE_CREATE"])
|
|
175
|
+
*/
|
|
176
|
+
export class DiscordEventSource extends BaseEventSource {
|
|
177
|
+
ws;
|
|
178
|
+
heartbeatInterval;
|
|
179
|
+
lastSequence = null;
|
|
180
|
+
sessionId;
|
|
181
|
+
resumeGatewayUrl;
|
|
182
|
+
constructor(config) {
|
|
183
|
+
super({ ...config, kind: "discord" });
|
|
184
|
+
}
|
|
185
|
+
async connect() {
|
|
186
|
+
const token = this.config.options.token;
|
|
187
|
+
if (!token)
|
|
188
|
+
throw new Error("Discord event source requires 'token' option");
|
|
189
|
+
const intents = this.config.options.intents ?? 513; // GUILDS + GUILD_MESSAGES
|
|
190
|
+
const gatewayUrl = this.resumeGatewayUrl ?? "wss://gateway.discord.gg/?v=10&encoding=json";
|
|
191
|
+
this._status = "connecting";
|
|
192
|
+
this.emit("connecting", { source: this.id });
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
this.ws = new WebSocket(gatewayUrl);
|
|
195
|
+
this.ws.on("open", () => {
|
|
196
|
+
// Wait for HELLO before identifying
|
|
197
|
+
});
|
|
198
|
+
this.ws.on("message", (raw) => {
|
|
199
|
+
const payload = JSON.parse(raw.toString());
|
|
200
|
+
const { op, d, s, t } = payload;
|
|
201
|
+
if (s !== null)
|
|
202
|
+
this.lastSequence = s;
|
|
203
|
+
switch (op) {
|
|
204
|
+
case 10: // HELLO
|
|
205
|
+
this.startHeartbeat(d.heartbeat_interval);
|
|
206
|
+
if (this.sessionId && this.resumeGatewayUrl) {
|
|
207
|
+
// Resume
|
|
208
|
+
this.ws.send(JSON.stringify({
|
|
209
|
+
op: 6,
|
|
210
|
+
d: { token, session_id: this.sessionId, seq: this.lastSequence },
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
// Identify
|
|
215
|
+
this.ws.send(JSON.stringify({
|
|
216
|
+
op: 2,
|
|
217
|
+
d: {
|
|
218
|
+
token,
|
|
219
|
+
intents,
|
|
220
|
+
properties: { os: "linux", browser: "marktoflow", device: "marktoflow" },
|
|
221
|
+
},
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
case 11: // HEARTBEAT_ACK
|
|
226
|
+
break;
|
|
227
|
+
case 0: // DISPATCH
|
|
228
|
+
if (t === "READY") {
|
|
229
|
+
this.sessionId = d.session_id;
|
|
230
|
+
this.resumeGatewayUrl = d.resume_gateway_url;
|
|
231
|
+
this._status = "connected";
|
|
232
|
+
this._connectedAt = new Date().toISOString();
|
|
233
|
+
this._reconnectAttempts = 0;
|
|
234
|
+
this.emit("connected", { source: this.id, user: d.user });
|
|
235
|
+
resolve();
|
|
236
|
+
}
|
|
237
|
+
// Emit all dispatch events
|
|
238
|
+
this.emitEvent(t, d, payload);
|
|
239
|
+
break;
|
|
240
|
+
case 7: // RECONNECT
|
|
241
|
+
this.ws.close();
|
|
242
|
+
this.handleDisconnect("server requested reconnect");
|
|
243
|
+
break;
|
|
244
|
+
case 9: // INVALID SESSION
|
|
245
|
+
this.sessionId = undefined;
|
|
246
|
+
this.resumeGatewayUrl = undefined;
|
|
247
|
+
this.ws.close();
|
|
248
|
+
this.handleDisconnect("invalid session");
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
this.ws.on("close", (code) => {
|
|
253
|
+
this.stopHeartbeat();
|
|
254
|
+
if (this._status === "connecting") {
|
|
255
|
+
reject(new Error(`Discord gateway closed during connect: ${code}`));
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
this.handleDisconnect(`code=${code}`);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
this.ws.on("error", (err) => {
|
|
262
|
+
if (this._status === "connecting") {
|
|
263
|
+
reject(err);
|
|
264
|
+
}
|
|
265
|
+
this.emit("error", err);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
async disconnect() {
|
|
270
|
+
this.stopHeartbeat();
|
|
271
|
+
if (this.ws) {
|
|
272
|
+
this.ws.removeAllListeners();
|
|
273
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
274
|
+
this.ws.close(1000, "disconnect");
|
|
275
|
+
}
|
|
276
|
+
this.ws = undefined;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
startHeartbeat(intervalMs) {
|
|
280
|
+
this.stopHeartbeat();
|
|
281
|
+
// Send first heartbeat immediately
|
|
282
|
+
this.ws?.send(JSON.stringify({ op: 1, d: this.lastSequence }));
|
|
283
|
+
this.heartbeatInterval = setInterval(() => {
|
|
284
|
+
this.ws?.send(JSON.stringify({ op: 1, d: this.lastSequence }));
|
|
285
|
+
}, intervalMs);
|
|
286
|
+
}
|
|
287
|
+
stopHeartbeat() {
|
|
288
|
+
if (this.heartbeatInterval) {
|
|
289
|
+
clearInterval(this.heartbeatInterval);
|
|
290
|
+
this.heartbeatInterval = undefined;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// ── Slack Socket Mode Source ─────────────────────────────────────────────────
|
|
295
|
+
/**
|
|
296
|
+
* Slack event source via Socket Mode (WebSocket).
|
|
297
|
+
*
|
|
298
|
+
* Required options:
|
|
299
|
+
* - appToken: Slack app-level token (xapp-...)
|
|
300
|
+
*
|
|
301
|
+
* Optional:
|
|
302
|
+
* - filter: Array of event types (e.g., ["message", "reaction_added"])
|
|
303
|
+
*/
|
|
304
|
+
export class SlackEventSource extends BaseEventSource {
|
|
305
|
+
ws;
|
|
306
|
+
constructor(config) {
|
|
307
|
+
super({ ...config, kind: "slack" });
|
|
308
|
+
}
|
|
309
|
+
async connect() {
|
|
310
|
+
const appToken = this.config.options.appToken;
|
|
311
|
+
if (!appToken)
|
|
312
|
+
throw new Error("Slack event source requires 'appToken' option");
|
|
313
|
+
this._status = "connecting";
|
|
314
|
+
this.emit("connecting", { source: this.id });
|
|
315
|
+
// Get WebSocket URL from Slack
|
|
316
|
+
const res = await fetch("https://slack.com/api/apps.connections.open", {
|
|
317
|
+
method: "POST",
|
|
318
|
+
headers: {
|
|
319
|
+
Authorization: `Bearer ${appToken}`,
|
|
320
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
const body = await res.json();
|
|
324
|
+
if (!body.ok || !body.url) {
|
|
325
|
+
throw new Error(`Slack connections.open failed: ${body.error ?? "unknown"}`);
|
|
326
|
+
}
|
|
327
|
+
return new Promise((resolve, reject) => {
|
|
328
|
+
this.ws = new WebSocket(body.url);
|
|
329
|
+
this.ws.on("open", () => {
|
|
330
|
+
this._status = "connected";
|
|
331
|
+
this._connectedAt = new Date().toISOString();
|
|
332
|
+
this._reconnectAttempts = 0;
|
|
333
|
+
this.emit("connected", { source: this.id });
|
|
334
|
+
resolve();
|
|
335
|
+
});
|
|
336
|
+
this.ws.on("message", (raw) => {
|
|
337
|
+
const payload = JSON.parse(raw.toString());
|
|
338
|
+
// Acknowledge envelope
|
|
339
|
+
if (payload.envelope_id) {
|
|
340
|
+
this.ws.send(JSON.stringify({ envelope_id: payload.envelope_id }));
|
|
341
|
+
}
|
|
342
|
+
if (payload.type === "events_api") {
|
|
343
|
+
const event = payload.payload?.event;
|
|
344
|
+
if (event) {
|
|
345
|
+
this.emitEvent(event.type, event, payload);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
else if (payload.type === "interactive") {
|
|
349
|
+
this.emitEvent("interactive", payload.payload ?? {}, payload);
|
|
350
|
+
}
|
|
351
|
+
else if (payload.type === "slash_commands") {
|
|
352
|
+
this.emitEvent("slash_command", payload.payload ?? {}, payload);
|
|
353
|
+
}
|
|
354
|
+
else if (payload.type === "disconnect") {
|
|
355
|
+
this.handleDisconnect("slack requested disconnect");
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
this.ws.on("close", () => {
|
|
359
|
+
if (this._status === "connecting") {
|
|
360
|
+
reject(new Error("Slack WebSocket closed during connect"));
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
this.handleDisconnect("connection closed");
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
this.ws.on("error", (err) => {
|
|
367
|
+
if (this._status === "connecting")
|
|
368
|
+
reject(err);
|
|
369
|
+
this.emit("error", err);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
async disconnect() {
|
|
374
|
+
if (this.ws) {
|
|
375
|
+
this.ws.removeAllListeners();
|
|
376
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
377
|
+
this.ws.close();
|
|
378
|
+
}
|
|
379
|
+
this.ws = undefined;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// ── Cron Event Source ────────────────────────────────────────────────────────
|
|
384
|
+
/**
|
|
385
|
+
* Cron event source — emits events on a schedule.
|
|
386
|
+
*
|
|
387
|
+
* Options:
|
|
388
|
+
* - schedule: Cron expression or interval string (e.g., "30m", "1h")
|
|
389
|
+
* - payload: Optional static payload to include with each event
|
|
390
|
+
*/
|
|
391
|
+
export class CronEventSource extends BaseEventSource {
|
|
392
|
+
timer;
|
|
393
|
+
intervalMs;
|
|
394
|
+
constructor(config) {
|
|
395
|
+
super({ ...config, kind: "cron" });
|
|
396
|
+
this.intervalMs = parseDuration(config.options.schedule);
|
|
397
|
+
}
|
|
398
|
+
async connect() {
|
|
399
|
+
this._status = "connected";
|
|
400
|
+
this._connectedAt = new Date().toISOString();
|
|
401
|
+
this.emit("connected", { source: this.id });
|
|
402
|
+
// Emit first event immediately if configured
|
|
403
|
+
if (this.config.options.immediate) {
|
|
404
|
+
this.tick();
|
|
405
|
+
}
|
|
406
|
+
this.timer = setInterval(() => this.tick(), this.intervalMs);
|
|
407
|
+
}
|
|
408
|
+
async disconnect() {
|
|
409
|
+
if (this.timer) {
|
|
410
|
+
clearInterval(this.timer);
|
|
411
|
+
this.timer = undefined;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
tick() {
|
|
415
|
+
const payload = this.config.options.payload ?? {};
|
|
416
|
+
this.emitEvent("tick", { ...payload, scheduledAt: new Date().toISOString() });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// ── SSE (Server-Sent Events) Source ──────────────────────────────────────────
|
|
420
|
+
/**
|
|
421
|
+
* HTTP Server-Sent Events listener.
|
|
422
|
+
*
|
|
423
|
+
* Options:
|
|
424
|
+
* - url: SSE endpoint URL
|
|
425
|
+
* - headers: Optional headers
|
|
426
|
+
*/
|
|
427
|
+
export class SSEEventSource extends BaseEventSource {
|
|
428
|
+
controller;
|
|
429
|
+
constructor(config) {
|
|
430
|
+
super({ ...config, kind: "http-stream" });
|
|
431
|
+
}
|
|
432
|
+
async connect() {
|
|
433
|
+
const url = this.config.options.url;
|
|
434
|
+
if (!url)
|
|
435
|
+
throw new Error("SSE event source requires 'url' option");
|
|
436
|
+
this._status = "connecting";
|
|
437
|
+
this.controller = new AbortController();
|
|
438
|
+
const headers = this.config.options.headers ?? {};
|
|
439
|
+
const res = await fetch(url, {
|
|
440
|
+
headers: { Accept: "text/event-stream", ...headers },
|
|
441
|
+
signal: this.controller.signal,
|
|
442
|
+
});
|
|
443
|
+
if (!res.ok) {
|
|
444
|
+
throw new Error(`SSE connect failed: ${res.status} ${res.statusText}`);
|
|
445
|
+
}
|
|
446
|
+
this._status = "connected";
|
|
447
|
+
this._connectedAt = new Date().toISOString();
|
|
448
|
+
this._reconnectAttempts = 0;
|
|
449
|
+
this.emit("connected", { source: this.id });
|
|
450
|
+
// Parse SSE stream
|
|
451
|
+
const reader = res.body?.getReader();
|
|
452
|
+
if (!reader)
|
|
453
|
+
throw new Error("SSE response has no body");
|
|
454
|
+
const decoder = new TextDecoder();
|
|
455
|
+
let buffer = "";
|
|
456
|
+
const readLoop = async () => {
|
|
457
|
+
try {
|
|
458
|
+
while (true) {
|
|
459
|
+
const { done, value } = await reader.read();
|
|
460
|
+
if (done)
|
|
461
|
+
break;
|
|
462
|
+
buffer += decoder.decode(value, { stream: true });
|
|
463
|
+
const lines = buffer.split("\n");
|
|
464
|
+
buffer = lines.pop() ?? "";
|
|
465
|
+
let eventType = "message";
|
|
466
|
+
let eventData = "";
|
|
467
|
+
for (const line of lines) {
|
|
468
|
+
if (line.startsWith("event:")) {
|
|
469
|
+
eventType = line.slice(6).trim();
|
|
470
|
+
}
|
|
471
|
+
else if (line.startsWith("data:")) {
|
|
472
|
+
eventData += (eventData ? "\n" : "") + line.slice(5).trim();
|
|
473
|
+
}
|
|
474
|
+
else if (line === "") {
|
|
475
|
+
// End of event
|
|
476
|
+
if (eventData) {
|
|
477
|
+
let parsed;
|
|
478
|
+
try {
|
|
479
|
+
parsed = JSON.parse(eventData);
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
parsed = { data: eventData };
|
|
483
|
+
}
|
|
484
|
+
this.emitEvent(eventType, parsed, eventData);
|
|
485
|
+
}
|
|
486
|
+
eventType = "message";
|
|
487
|
+
eventData = "";
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
if (err.name !== "AbortError") {
|
|
494
|
+
this.emit("error", err);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
this.handleDisconnect("stream ended");
|
|
498
|
+
};
|
|
499
|
+
readLoop();
|
|
500
|
+
}
|
|
501
|
+
async disconnect() {
|
|
502
|
+
this.controller?.abort();
|
|
503
|
+
this.controller = undefined;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// ── RSS Event Source ─────────────────────────────────────────────────────────
|
|
507
|
+
/**
|
|
508
|
+
* RSS/Atom feed event source — polls a feed and emits events for new items.
|
|
509
|
+
*
|
|
510
|
+
* Options:
|
|
511
|
+
* - url: Feed URL (required)
|
|
512
|
+
* - interval: Polling interval as duration string (default: "5m")
|
|
513
|
+
* - immediate: Poll immediately on connect (default: false)
|
|
514
|
+
* - headers: Custom HTTP headers for feed requests
|
|
515
|
+
* - maxItems: Max new items to emit per poll (default: unlimited)
|
|
516
|
+
*/
|
|
517
|
+
export class RssEventSource extends BaseEventSource {
|
|
518
|
+
timer;
|
|
519
|
+
intervalMs;
|
|
520
|
+
seenIds = new Set();
|
|
521
|
+
firstPoll = true;
|
|
522
|
+
static MAX_SEEN_IDS = 10_000;
|
|
523
|
+
constructor(config) {
|
|
524
|
+
super({ ...config, kind: "rss" });
|
|
525
|
+
const interval = config.options.interval ?? "5m";
|
|
526
|
+
this.intervalMs = parseDuration(interval);
|
|
527
|
+
}
|
|
528
|
+
async connect() {
|
|
529
|
+
const url = this.config.options.url;
|
|
530
|
+
if (!url)
|
|
531
|
+
throw new Error("RSS event source requires 'url' option");
|
|
532
|
+
this._status = "connected";
|
|
533
|
+
this._connectedAt = new Date().toISOString();
|
|
534
|
+
this.emit("connected", { source: this.id });
|
|
535
|
+
if (this.config.options.immediate) {
|
|
536
|
+
await this.poll();
|
|
537
|
+
}
|
|
538
|
+
this.timer = setInterval(() => {
|
|
539
|
+
this.poll().catch((err) => {
|
|
540
|
+
this.emit("error", err);
|
|
541
|
+
});
|
|
542
|
+
}, this.intervalMs);
|
|
543
|
+
}
|
|
544
|
+
async disconnect() {
|
|
545
|
+
if (this.timer) {
|
|
546
|
+
clearInterval(this.timer);
|
|
547
|
+
this.timer = undefined;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
async poll() {
|
|
551
|
+
const url = this.config.options.url;
|
|
552
|
+
const headers = this.config.options.headers ?? {};
|
|
553
|
+
const maxItems = this.config.options.maxItems;
|
|
554
|
+
let xml;
|
|
555
|
+
try {
|
|
556
|
+
const res = await fetch(url, {
|
|
557
|
+
headers: { Accept: "application/rss+xml, application/atom+xml, application/xml, text/xml", ...headers },
|
|
558
|
+
});
|
|
559
|
+
if (!res.ok) {
|
|
560
|
+
throw new Error(`RSS fetch failed: ${res.status} ${res.statusText}`);
|
|
561
|
+
}
|
|
562
|
+
xml = await res.text();
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
this.emit("error", err);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const items = parseRssItems(xml);
|
|
569
|
+
const newItems = [];
|
|
570
|
+
for (const item of items) {
|
|
571
|
+
const id = item.guid || item.link || item.title;
|
|
572
|
+
if (!id)
|
|
573
|
+
continue;
|
|
574
|
+
if (!this.seenIds.has(id)) {
|
|
575
|
+
this.seenIds.add(id);
|
|
576
|
+
if (!this.firstPoll) {
|
|
577
|
+
newItems.push(item);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
this.firstPoll = false;
|
|
582
|
+
// Evict oldest entries if seenIds grows too large
|
|
583
|
+
if (this.seenIds.size > RssEventSource.MAX_SEEN_IDS) {
|
|
584
|
+
const entries = Array.from(this.seenIds);
|
|
585
|
+
const toRemove = entries.length - RssEventSource.MAX_SEEN_IDS;
|
|
586
|
+
for (let i = 0; i < toRemove; i++) {
|
|
587
|
+
this.seenIds.delete(entries[i]);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const toEmit = maxItems ? newItems.slice(0, maxItems) : newItems;
|
|
591
|
+
for (const item of toEmit) {
|
|
592
|
+
this.emitEvent("new_item", {
|
|
593
|
+
title: item.title,
|
|
594
|
+
link: item.link,
|
|
595
|
+
description: item.description,
|
|
596
|
+
pubDate: item.pubDate,
|
|
597
|
+
guid: item.guid,
|
|
598
|
+
author: item.author,
|
|
599
|
+
categories: item.categories,
|
|
600
|
+
feedUrl: url,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
function extractTag(xml, tag) {
|
|
606
|
+
const re = new RegExp(`<${tag}[^>]*>\\s*(?:<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>|([\\s\\S]*?))\\s*</${tag}>`, "i");
|
|
607
|
+
const m = xml.match(re);
|
|
608
|
+
if (!m)
|
|
609
|
+
return "";
|
|
610
|
+
return (m[1] ?? m[2] ?? "").trim();
|
|
611
|
+
}
|
|
612
|
+
function extractAtomLink(xml) {
|
|
613
|
+
const m = xml.match(/<link[^>]+href\s*=\s*["']([^"']+)["'][^>]*\/?>/i);
|
|
614
|
+
return m ? m[1] : "";
|
|
615
|
+
}
|
|
616
|
+
function extractAllTags(xml, tag) {
|
|
617
|
+
const re = new RegExp(`<${tag}[^>]*>\\s*(?:<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>|([\\s\\S]*?))\\s*</${tag}>`, "gi");
|
|
618
|
+
const results = [];
|
|
619
|
+
let m;
|
|
620
|
+
while ((m = re.exec(xml)) !== null) {
|
|
621
|
+
const val = (m[1] ?? m[2] ?? "").trim();
|
|
622
|
+
if (val)
|
|
623
|
+
results.push(val);
|
|
624
|
+
}
|
|
625
|
+
return results;
|
|
626
|
+
}
|
|
627
|
+
function parseRssItems(xml) {
|
|
628
|
+
const items = [];
|
|
629
|
+
// Detect RSS 2.0 vs Atom
|
|
630
|
+
const isAtom = /<feed[\s>]/i.test(xml);
|
|
631
|
+
if (isAtom) {
|
|
632
|
+
// Atom: split on <entry>
|
|
633
|
+
const entries = xml.split(/<entry[\s>]/i).slice(1);
|
|
634
|
+
for (const entry of entries) {
|
|
635
|
+
const block = entry.split(/<\/entry>/i)[0];
|
|
636
|
+
items.push({
|
|
637
|
+
title: extractTag(block, "title"),
|
|
638
|
+
link: extractAtomLink(block) || extractTag(block, "link"),
|
|
639
|
+
description: extractTag(block, "summary") || extractTag(block, "content"),
|
|
640
|
+
pubDate: extractTag(block, "updated") || extractTag(block, "published"),
|
|
641
|
+
guid: extractTag(block, "id"),
|
|
642
|
+
author: extractTag(block, "name") || extractTag(block, "author"),
|
|
643
|
+
categories: extractAllTags(block, "category"),
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
// RSS 2.0: split on <item>
|
|
649
|
+
const rawItems = xml.split(/<item[\s>]/i).slice(1);
|
|
650
|
+
for (const raw of rawItems) {
|
|
651
|
+
const block = raw.split(/<\/item>/i)[0];
|
|
652
|
+
items.push({
|
|
653
|
+
title: extractTag(block, "title"),
|
|
654
|
+
link: extractTag(block, "link"),
|
|
655
|
+
description: extractTag(block, "description"),
|
|
656
|
+
pubDate: extractTag(block, "pubDate"),
|
|
657
|
+
guid: extractTag(block, "guid"),
|
|
658
|
+
author: extractTag(block, "author") || extractTag(block, "dc:creator"),
|
|
659
|
+
categories: extractAllTags(block, "category"),
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return items;
|
|
664
|
+
}
|
|
665
|
+
// ── Factory ──────────────────────────────────────────────────────────────────
|
|
666
|
+
export function createEventSource(config) {
|
|
667
|
+
switch (config.kind) {
|
|
668
|
+
case "websocket":
|
|
669
|
+
return new WebSocketEventSource(config);
|
|
670
|
+
case "discord":
|
|
671
|
+
return new DiscordEventSource(config);
|
|
672
|
+
case "slack":
|
|
673
|
+
return new SlackEventSource(config);
|
|
674
|
+
case "cron":
|
|
675
|
+
return new CronEventSource(config);
|
|
676
|
+
case "http-stream":
|
|
677
|
+
return new SSEEventSource(config);
|
|
678
|
+
case "rss":
|
|
679
|
+
return new RssEventSource(config);
|
|
680
|
+
default:
|
|
681
|
+
throw new Error(`Unknown event source kind: ${config.kind}`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
// ── Event Source Manager ─────────────────────────────────────────────────────
|
|
685
|
+
/**
|
|
686
|
+
* Manages multiple event sources and provides a unified event stream.
|
|
687
|
+
*/
|
|
688
|
+
export class EventSourceManager extends EventEmitter {
|
|
689
|
+
sources = new Map();
|
|
690
|
+
/** Add and connect an event source */
|
|
691
|
+
async add(config) {
|
|
692
|
+
if (this.sources.has(config.id)) {
|
|
693
|
+
throw new Error(`Event source '${config.id}' already exists`);
|
|
694
|
+
}
|
|
695
|
+
const source = createEventSource(config);
|
|
696
|
+
// Forward events
|
|
697
|
+
source.on("event", (event) => {
|
|
698
|
+
this.emit("event", event);
|
|
699
|
+
});
|
|
700
|
+
source.on("connected", (info) => this.emit("source:connected", info));
|
|
701
|
+
source.on("disconnected", (info) => this.emit("source:disconnected", info));
|
|
702
|
+
source.on("error", (err) => this.emit("source:error", { source: config.id, error: err }));
|
|
703
|
+
this.sources.set(config.id, source);
|
|
704
|
+
await source.connect();
|
|
705
|
+
return source;
|
|
706
|
+
}
|
|
707
|
+
/** Remove and disconnect an event source */
|
|
708
|
+
async remove(id) {
|
|
709
|
+
const source = this.sources.get(id);
|
|
710
|
+
if (source) {
|
|
711
|
+
await source.stop();
|
|
712
|
+
source.removeAllListeners();
|
|
713
|
+
this.sources.delete(id);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/** Get a source by id */
|
|
717
|
+
get(id) {
|
|
718
|
+
return this.sources.get(id);
|
|
719
|
+
}
|
|
720
|
+
/** Get stats for all sources */
|
|
721
|
+
stats() {
|
|
722
|
+
return Array.from(this.sources.values()).map((s) => s.stats);
|
|
723
|
+
}
|
|
724
|
+
/** Wait for the next event from any source, with optional filter */
|
|
725
|
+
waitForEvent(options) {
|
|
726
|
+
return new Promise((resolve, reject) => {
|
|
727
|
+
let timer;
|
|
728
|
+
const handler = (event) => {
|
|
729
|
+
if (options?.source && event.source !== options.source)
|
|
730
|
+
return;
|
|
731
|
+
if (options?.type && event.type !== options.type)
|
|
732
|
+
return;
|
|
733
|
+
if (options?.filter && !options.filter(event))
|
|
734
|
+
return;
|
|
735
|
+
if (timer)
|
|
736
|
+
clearTimeout(timer);
|
|
737
|
+
this.removeListener("event", handler);
|
|
738
|
+
resolve(event);
|
|
739
|
+
};
|
|
740
|
+
this.on("event", handler);
|
|
741
|
+
if (options?.timeout) {
|
|
742
|
+
timer = setTimeout(() => {
|
|
743
|
+
this.removeListener("event", handler);
|
|
744
|
+
reject(new Error(`Timed out waiting for event after ${options.timeout}ms`));
|
|
745
|
+
}, options.timeout);
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
/** Stop all sources */
|
|
750
|
+
async stopAll() {
|
|
751
|
+
const promises = Array.from(this.sources.values()).map((s) => s.stop());
|
|
752
|
+
await Promise.all(promises);
|
|
753
|
+
this.sources.clear();
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// parseDuration imported from ./utils/duration.js (single source of truth)
|
|
757
|
+
//# sourceMappingURL=event-source.js.map
|