@interactive-inc/flume 0.1.0 → 0.2.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/discord.js CHANGED
@@ -1,2 +1,537 @@
1
- import { t as FlumeDiscordSource } from "./discord-source-Q4JRIbNs.js";
2
- export { FlumeDiscordSource };
1
+ import { n as createFlumeDefaultDeps, t as FlumeLogger } from "./logger-CpGB9WO_.js";
2
+ import { a as FlumeConnectionError, i as FlumeParseError, n as FlumeReconnector, r as resolveFlumeReconnectConfig, t as scheduleFlumeReconnect } from "./schedule-reconnect-DSxZJG3h.js";
3
+ import { t as FlumeSerialQueue } from "./serial-queue-ExmlnpzQ.js";
4
+ import { n as isRecord, t as safeJsonParse } from "./safe-json-parse-WCg_x1JS.js";
5
+ import { z } from "zod/v4";
6
+ //#region lib/discord/extract-discord-meta.ts
7
+ function extractDiscordMeta(eventName, eventData) {
8
+ const meta = { event_type: eventName };
9
+ if (typeof eventData.channel_id === "string") meta.channel_id = eventData.channel_id;
10
+ if (typeof eventData.guild_id === "string") meta.guild_id = eventData.guild_id;
11
+ if (isRecord(eventData.author) && typeof eventData.author.id === "string") meta.user_id = eventData.author.id;
12
+ return meta;
13
+ }
14
+ //#endregion
15
+ //#region lib/discord/discord-heartbeat.ts
16
+ var FlumeDiscordHeartbeat = class {
17
+ props;
18
+ timer = null;
19
+ ackReceived = true;
20
+ constructor(props) {
21
+ this.props = props;
22
+ }
23
+ start(intervalMs) {
24
+ this.stop();
25
+ this.ackReceived = true;
26
+ this.timer = this.props.deps.setInterval(() => {
27
+ if (!this.ackReceived) {
28
+ this.props.onZombie();
29
+ return;
30
+ }
31
+ this.ackReceived = false;
32
+ this.props.onSend();
33
+ }, intervalMs);
34
+ }
35
+ stop() {
36
+ if (this.timer === null) return;
37
+ this.props.deps.clearInterval(this.timer);
38
+ this.timer = null;
39
+ }
40
+ ack() {
41
+ this.ackReceived = true;
42
+ }
43
+ isRunning() {
44
+ return this.timer !== null;
45
+ }
46
+ };
47
+ //#endregion
48
+ //#region lib/discord/discord-gateway-session.ts
49
+ var FlumeDiscordGatewaySession = class FlumeDiscordGatewaySession {
50
+ sessionId;
51
+ resumeUrl;
52
+ seq;
53
+ constructor(props) {
54
+ this.sessionId = props.sessionId;
55
+ this.resumeUrl = props.resumeUrl;
56
+ this.seq = props.seq;
57
+ Object.freeze(this);
58
+ }
59
+ static empty() {
60
+ return new FlumeDiscordGatewaySession({
61
+ sessionId: null,
62
+ resumeUrl: null,
63
+ seq: null
64
+ });
65
+ }
66
+ canResume() {
67
+ return this.sessionId !== null;
68
+ }
69
+ withSeq(seq) {
70
+ return new FlumeDiscordGatewaySession({
71
+ sessionId: this.sessionId,
72
+ resumeUrl: this.resumeUrl,
73
+ seq
74
+ });
75
+ }
76
+ withReady(sessionId, resumeUrl) {
77
+ return new FlumeDiscordGatewaySession({
78
+ sessionId,
79
+ resumeUrl,
80
+ seq: this.seq
81
+ });
82
+ }
83
+ withReset() {
84
+ return FlumeDiscordGatewaySession.empty();
85
+ }
86
+ };
87
+ //#endregion
88
+ //#region lib/discord/discord-gateway-message-schema.ts
89
+ const FlumeGatewayMessageSchema = z.object({
90
+ op: z.number(),
91
+ d: z.record(z.string(), z.unknown()).nullable(),
92
+ s: z.number().nullable(),
93
+ t: z.string().nullable()
94
+ });
95
+ //#endregion
96
+ //#region lib/discord/parse-discord-gateway-message.ts
97
+ function parseDiscordGatewayMessage(raw) {
98
+ const json = safeJsonParse(raw);
99
+ const parsed = FlumeGatewayMessageSchema.safeParse(json);
100
+ if (!parsed.success) return new FlumeParseError(`invalid gateway message: ${raw.slice(0, 200)}`);
101
+ return parsed.data;
102
+ }
103
+ //#endregion
104
+ //#region lib/discord/discord-gateway.ts
105
+ const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
106
+ function framePreview(raw) {
107
+ return raw.length > 200 ? `${raw.slice(0, 200)}... (${raw.length} bytes)` : raw;
108
+ }
109
+ const OP_DISPATCH = 0;
110
+ const OP_HEARTBEAT = 1;
111
+ const OP_IDENTIFY = 2;
112
+ const OP_RESUME = 6;
113
+ const OP_RECONNECT = 7;
114
+ const OP_INVALID_SESSION = 9;
115
+ const OP_HELLO = 10;
116
+ const OP_HEARTBEAT_ACK = 11;
117
+ const OP_NAMES = {
118
+ [OP_DISPATCH]: "DISPATCH",
119
+ [OP_HEARTBEAT]: "HEARTBEAT",
120
+ [OP_IDENTIFY]: "IDENTIFY",
121
+ [OP_RESUME]: "RESUME",
122
+ [OP_RECONNECT]: "RECONNECT",
123
+ [OP_INVALID_SESSION]: "INVALID_SESSION",
124
+ [OP_HELLO]: "HELLO",
125
+ [OP_HEARTBEAT_ACK]: "HEARTBEAT_ACK"
126
+ };
127
+ var FlumeDiscordGateway = class {
128
+ props;
129
+ log;
130
+ ws = null;
131
+ heartbeat = null;
132
+ session = FlumeDiscordGatewaySession.empty();
133
+ stopped = false;
134
+ pendingResolve = null;
135
+ pendingResolved = false;
136
+ constructor(props) {
137
+ this.props = props;
138
+ this.log = new FlumeLogger({
139
+ source: "discord.gateway",
140
+ handler: props.onLog,
141
+ deps: props.deps
142
+ });
143
+ }
144
+ connect(url) {
145
+ const target = url ?? GATEWAY_URL;
146
+ this.log.info({
147
+ action: "connect.start",
148
+ message: `url=${new URL(target).hostname}`
149
+ });
150
+ this.pendingResolved = false;
151
+ return new Promise((resolve) => {
152
+ this.pendingResolve = resolve;
153
+ const socket = new this.props.deps.WebSocket(target);
154
+ this.ws = socket;
155
+ socket.addEventListener("message", (ev) => this.onMessage(String(ev.data), socket));
156
+ socket.addEventListener("close", (ev) => this.onClose(ev));
157
+ socket.addEventListener("error", () => this.onError());
158
+ });
159
+ }
160
+ disconnect() {
161
+ this.log.info({
162
+ action: "disconnect",
163
+ message: "shutting down gateway"
164
+ });
165
+ this.stopped = true;
166
+ this.heartbeat?.stop();
167
+ if (this.ws) {
168
+ this.ws.close(1e3, "shutdown");
169
+ this.ws = null;
170
+ }
171
+ }
172
+ isConnected() {
173
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
174
+ }
175
+ completeConnect(error) {
176
+ if (this.pendingResolved || !this.pendingResolve) return;
177
+ this.pendingResolved = true;
178
+ this.pendingResolve(error);
179
+ }
180
+ onMessage(raw, socket) {
181
+ this.log.debug({
182
+ action: "ws.recv",
183
+ message: framePreview(raw)
184
+ });
185
+ const parsed = parseDiscordGatewayMessage(raw);
186
+ if (parsed instanceof FlumeParseError) {
187
+ this.log.error({
188
+ action: "ws.parse-error",
189
+ message: parsed.message,
190
+ error: parsed
191
+ });
192
+ return;
193
+ }
194
+ this.log.debug({
195
+ action: "ws.frame",
196
+ message: `op=${OP_NAMES[parsed.op] ?? parsed.op} t=${parsed.t ?? "-"} s=${parsed.s ?? "-"}`,
197
+ detail: {
198
+ op: parsed.op,
199
+ t: parsed.t,
200
+ s: parsed.s
201
+ }
202
+ });
203
+ if (parsed.s !== null) this.session = this.session.withSeq(parsed.s);
204
+ if (parsed.op === OP_HELLO) return this.onHello(parsed);
205
+ if (parsed.op === OP_HEARTBEAT_ACK) return this.onHeartbeatAck();
206
+ if (parsed.op === OP_HEARTBEAT) return this.onHeartbeatRequest();
207
+ if (parsed.op === OP_RECONNECT) return this.onReconnectRequest(socket);
208
+ if (parsed.op === OP_INVALID_SESSION) return this.onInvalidSession(parsed, socket);
209
+ if (parsed.op === OP_DISPATCH) return this.onDispatch(parsed);
210
+ this.log.warn({
211
+ action: "ws.unknown-op",
212
+ message: `unknown op=${parsed.op}`,
213
+ detail: { op: parsed.op }
214
+ });
215
+ }
216
+ onHello(msg) {
217
+ const interval = typeof msg.d?.heartbeat_interval === "number" ? msg.d.heartbeat_interval : 0;
218
+ this.log.info({
219
+ action: "hello",
220
+ message: `heartbeat_interval=${interval}ms`
221
+ });
222
+ this.heartbeat = new FlumeDiscordHeartbeat({
223
+ deps: this.props.deps,
224
+ onSend: () => {
225
+ this.log.debug({
226
+ action: "heartbeat.send",
227
+ message: `seq=${this.session.seq}`
228
+ });
229
+ this.send({
230
+ op: OP_HEARTBEAT,
231
+ d: this.session.seq
232
+ });
233
+ },
234
+ onZombie: () => {
235
+ this.log.warn({
236
+ action: "heartbeat.zombie",
237
+ message: "no ACK received, closing connection"
238
+ });
239
+ this.ws?.close(4009, "zombie connection");
240
+ }
241
+ });
242
+ this.heartbeat.start(interval);
243
+ if (this.session.canResume()) this.sendResume();
244
+ else this.sendIdentify();
245
+ }
246
+ onHeartbeatAck() {
247
+ this.log.debug({
248
+ action: "heartbeat.ack",
249
+ message: "received"
250
+ });
251
+ this.heartbeat?.ack();
252
+ }
253
+ onHeartbeatRequest() {
254
+ this.log.debug({
255
+ action: "heartbeat.requested",
256
+ message: "server requested heartbeat"
257
+ });
258
+ this.send({
259
+ op: OP_HEARTBEAT,
260
+ d: this.session.seq
261
+ });
262
+ }
263
+ onReconnectRequest(socket) {
264
+ this.log.info({
265
+ action: "reconnect.requested",
266
+ message: "server requested reconnect"
267
+ });
268
+ socket.close(4e3, "reconnect requested");
269
+ }
270
+ onInvalidSession(msg, socket) {
271
+ const resumable = !!msg.d;
272
+ this.log.warn({
273
+ action: "invalid-session",
274
+ message: `resumable=${resumable}`
275
+ });
276
+ this.session = this.session.withReset();
277
+ if (resumable) {
278
+ const delay = 1e3 + this.props.deps.random() * 4e3;
279
+ this.log.info({
280
+ action: "identify.delayed",
281
+ message: `re-identify in ${Math.round(delay)}ms`
282
+ });
283
+ this.props.deps.setTimeout(() => this.sendIdentify(), delay);
284
+ } else socket.close(4e3, "invalid session");
285
+ }
286
+ onDispatch(msg) {
287
+ if (msg.t === "READY" && msg.d) {
288
+ const sessionId = typeof msg.d.session_id === "string" ? msg.d.session_id : "";
289
+ const resumeUrl = typeof msg.d.resume_gateway_url === "string" ? msg.d.resume_gateway_url : "";
290
+ this.session = this.session.withReady(sessionId, resumeUrl);
291
+ this.log.info({
292
+ action: "ready",
293
+ message: `session=${sessionId}`
294
+ });
295
+ this.props.onStatus("connected");
296
+ this.completeConnect(null);
297
+ }
298
+ if (msg.t === "RESUMED") {
299
+ this.log.info({
300
+ action: "resumed",
301
+ message: `session=${this.session.sessionId} seq=${this.session.seq}`
302
+ });
303
+ this.props.onStatus("connected");
304
+ this.completeConnect(null);
305
+ }
306
+ if (msg.t && msg.d) this.props.onDispatch(msg.t, msg.d);
307
+ else if (msg.t) this.props.onDispatch(msg.t, {});
308
+ }
309
+ onClose(ev) {
310
+ this.log.info({
311
+ action: "ws.close",
312
+ message: `code=${ev.code} reason=${ev.reason || "none"}`,
313
+ detail: {
314
+ code: ev.code,
315
+ reason: ev.reason
316
+ }
317
+ });
318
+ this.ws = null;
319
+ this.heartbeat?.stop();
320
+ this.props.onStatus("disconnected");
321
+ this.completeConnect(new FlumeConnectionError(`WebSocket closed before ready (code=${ev.code})`));
322
+ }
323
+ onError() {
324
+ this.log.error({
325
+ action: "ws.error",
326
+ message: "WebSocket error event"
327
+ });
328
+ this.completeConnect(new FlumeConnectionError("WebSocket connection error"));
329
+ }
330
+ send(input) {
331
+ const payload = JSON.stringify({
332
+ op: input.op,
333
+ d: input.d ?? null
334
+ });
335
+ this.log.debug({
336
+ action: "ws.send",
337
+ message: `op=${OP_NAMES[input.op] ?? input.op}`,
338
+ detail: { op: input.op }
339
+ });
340
+ this.ws?.send(payload);
341
+ this.log.debug({
342
+ action: "ws.sent",
343
+ message: framePreview(payload)
344
+ });
345
+ }
346
+ sendIdentify() {
347
+ this.log.info({
348
+ action: "identify",
349
+ message: `intents=${this.props.intents}`
350
+ });
351
+ this.send({
352
+ op: OP_IDENTIFY,
353
+ d: {
354
+ token: this.props.token,
355
+ intents: this.props.intents,
356
+ properties: {
357
+ os: "linux",
358
+ browser: "open-flume",
359
+ device: "open-flume"
360
+ }
361
+ }
362
+ });
363
+ }
364
+ sendResume() {
365
+ this.log.info({
366
+ action: "resume",
367
+ message: `session=${this.session.sessionId} seq=${this.session.seq}`
368
+ });
369
+ this.send({
370
+ op: OP_RESUME,
371
+ d: {
372
+ token: this.props.token,
373
+ session_id: this.session.sessionId,
374
+ seq: this.session.seq
375
+ }
376
+ });
377
+ }
378
+ };
379
+ //#endregion
380
+ //#region lib/discord/discord-gateway-intents.ts
381
+ const FlumeDiscordGatewayIntents = {
382
+ Guilds: 1,
383
+ GuildMembers: 2,
384
+ GuildModeration: 4,
385
+ GuildExpressions: 8,
386
+ GuildIntegrations: 16,
387
+ GuildWebhooks: 32,
388
+ GuildInvites: 64,
389
+ GuildVoiceStates: 128,
390
+ GuildPresences: 256,
391
+ GuildMessages: 512,
392
+ GuildMessageReactions: 1024,
393
+ GuildMessageTyping: 2048,
394
+ DirectMessages: 4096,
395
+ DirectMessageReactions: 8192,
396
+ DirectMessageTyping: 16384,
397
+ MessageContent: 32768,
398
+ GuildScheduledEvents: 65536,
399
+ AutoModerationConfiguration: 1 << 20,
400
+ AutoModerationExecution: 1 << 21,
401
+ GuildMessagePolls: 1 << 24,
402
+ DirectMessagePolls: 1 << 25
403
+ };
404
+ //#endregion
405
+ //#region lib/discord/discord-source.ts
406
+ const DEFAULT_INTENTS = FlumeDiscordGatewayIntents.Guilds | FlumeDiscordGatewayIntents.GuildMessages | FlumeDiscordGatewayIntents.DirectMessages | FlumeDiscordGatewayIntents.MessageContent;
407
+ var FlumeDiscordSource = class {
408
+ options;
409
+ name = "discord";
410
+ gateway = null;
411
+ reconnector = null;
412
+ handler = null;
413
+ currentStatus = "disconnected";
414
+ log;
415
+ deps;
416
+ queue = new FlumeSerialQueue();
417
+ constructor(options) {
418
+ this.options = options;
419
+ this.deps = options.deps ?? createFlumeDefaultDeps();
420
+ this.log = new FlumeLogger({
421
+ source: "discord",
422
+ handler: options.onLog,
423
+ deps: this.deps
424
+ });
425
+ const rc = resolveFlumeReconnectConfig(options.reconnect);
426
+ if (rc) this.reconnector = new FlumeReconnector({
427
+ ...rc,
428
+ deps: this.deps
429
+ });
430
+ }
431
+ async start(handler) {
432
+ if (this.options.signal?.aborted) return /* @__PURE__ */ new Error("Discord source: signal already aborted");
433
+ this.options.signal?.addEventListener("abort", () => this.stop(), { once: true });
434
+ this.handler = handler;
435
+ this.log.info({
436
+ action: "start",
437
+ message: "starting Discord source"
438
+ });
439
+ return this.connectInternal();
440
+ }
441
+ async stop() {
442
+ this.log.info({
443
+ action: "stop",
444
+ message: "stopping Discord source"
445
+ });
446
+ if (this.reconnector && !this.reconnector.aborted) this.log.debug({
447
+ action: "reconnect.cancel",
448
+ message: "aborting reconnector"
449
+ });
450
+ this.reconnector?.cancel();
451
+ this.gateway?.disconnect();
452
+ this.gateway = null;
453
+ this.handler = null;
454
+ await this.queue.drain();
455
+ this.setStatus("disconnected");
456
+ }
457
+ status() {
458
+ return this.currentStatus;
459
+ }
460
+ async connectInternal(resumeUrl) {
461
+ this.setStatus("connecting");
462
+ this.gateway = new FlumeDiscordGateway({
463
+ token: this.options.token,
464
+ intents: this.options.intents ?? DEFAULT_INTENTS,
465
+ onLog: this.options.onLog,
466
+ deps: this.deps,
467
+ onDispatch: (eventName, eventData) => this.handleDispatch(eventName, eventData),
468
+ onStatus: (status) => this.handleGatewayStatus(status)
469
+ });
470
+ const error = await this.gateway.connect(resumeUrl);
471
+ if (error instanceof FlumeConnectionError) {
472
+ this.log.error({
473
+ action: "connect.failed",
474
+ message: error.message,
475
+ error
476
+ });
477
+ if (!this.reconnector || this.reconnector.aborted) {
478
+ this.setStatus("disconnected");
479
+ return error;
480
+ }
481
+ this.scheduleReconnect();
482
+ }
483
+ }
484
+ handleDispatch(eventName, eventData) {
485
+ const event = {
486
+ source: "discord",
487
+ type: eventName,
488
+ data: eventData,
489
+ meta: extractDiscordMeta(eventName, eventData),
490
+ receivedAt: this.deps.now()
491
+ };
492
+ this.queue.add(async () => {
493
+ try {
494
+ await this.handler?.(event);
495
+ } catch (err) {
496
+ this.log.error({
497
+ action: "handler.error",
498
+ message: "user handler threw",
499
+ error: err instanceof Error ? err : new Error(String(err))
500
+ });
501
+ }
502
+ });
503
+ }
504
+ handleGatewayStatus(status) {
505
+ if (status === "connected") {
506
+ if (this.reconnector && this.reconnector.attempt > 0) this.log.info({
507
+ action: "reconnect.reset",
508
+ message: `cleared ${this.reconnector.attempt} attempts`
509
+ });
510
+ this.reconnector?.reset();
511
+ this.setStatus("connected");
512
+ }
513
+ if (status === "disconnected" && !this.gateway?.stopped) this.scheduleReconnect();
514
+ }
515
+ scheduleReconnect() {
516
+ const url = this.gateway?.session.resumeUrl ?? void 0;
517
+ scheduleFlumeReconnect({
518
+ reconnector: this.reconnector,
519
+ log: this.log,
520
+ setStatus: (status) => this.setStatus(status),
521
+ retry: () => {
522
+ this.connectInternal(url);
523
+ }
524
+ });
525
+ }
526
+ setStatus(next) {
527
+ if (this.currentStatus === next) return;
528
+ this.log.info({
529
+ action: "status",
530
+ message: `${this.currentStatus} → ${next}`
531
+ });
532
+ this.currentStatus = next;
533
+ this.options.onStatus?.(next);
534
+ }
535
+ };
536
+ //#endregion
537
+ export { FlumeDiscordGateway, FlumeDiscordGatewayIntents, FlumeDiscordGatewaySession, FlumeDiscordHeartbeat, FlumeDiscordSource, FlumeGatewayMessageSchema, extractDiscordMeta, parseDiscordGatewayMessage };
package/dist/github.d.ts CHANGED
@@ -1,19 +1,65 @@
1
- import { a as FlumeGitHubSourceOptions, i as FlumeGitHubNotification, o as FlumeHandler, y as FlumeStatus } from "./types-BVQSU336.js";
1
+ import { E as FlumeGitHubNotificationSchema, a as FlumeGitHubSourceOptions, c as FlumeLogHandler, i as FlumeGitHubNotification, o as FlumeHandler, p as FlumeRuntimeDeps, x as FlumeStatus } from "./types-tnOPBc1p.js";
2
2
 
3
3
  //#region lib/github/github-source.d.ts
4
4
  declare class FlumeGitHubSource {
5
5
  private readonly options;
6
+ readonly name: "github";
6
7
  private poller;
7
8
  private currentStatus;
8
9
  private readonly log;
9
10
  private readonly deps;
11
+ private readonly queue;
10
12
  constructor(options: FlumeGitHubSourceOptions);
11
- start(handler: FlumeHandler): Promise<void>;
13
+ start(handler: FlumeHandler): Promise<void | Error>;
12
14
  stop(): Promise<void>;
13
15
  status(): FlumeStatus;
14
16
  private handleNotifications;
15
17
  private setStatus;
16
- static extractMeta(notification: FlumeGitHubNotification): Record<string, string>;
17
18
  }
18
19
  //#endregion
19
- export { FlumeGitHubSource };
20
+ //#region lib/github/extract-github-meta.d.ts
21
+ declare function extractGitHubMeta(notification: FlumeGitHubNotification): Record<string, string>;
22
+ //#endregion
23
+ //#region lib/github/github-poller.d.ts
24
+ type Deps = Pick<FlumeRuntimeDeps, "fetch" | "setInterval" | "clearInterval" | "now">;
25
+ type Props$1 = {
26
+ token: string;
27
+ interval: number;
28
+ onNotifications: (notifications: FlumeGitHubNotification[]) => void;
29
+ onConnected: () => void;
30
+ onDisconnected: (detail: string) => void;
31
+ onLog?: FlumeLogHandler;
32
+ deps: Deps;
33
+ };
34
+ declare class FlumeGitHubPoller {
35
+ private readonly props;
36
+ private readonly log;
37
+ private readonly cache;
38
+ private timer;
39
+ private since;
40
+ private bootstrapped;
41
+ stopped: boolean;
42
+ private consecutiveErrors;
43
+ constructor(props: Props$1);
44
+ start(): Promise<void>;
45
+ stop(): void;
46
+ private poll;
47
+ private processNotifications;
48
+ private safeFetch;
49
+ }
50
+ //#endregion
51
+ //#region lib/github/github-seen-cache.d.ts
52
+ type Props = {
53
+ maxSize: number;
54
+ };
55
+ declare class FlumeGitHubSeenCache {
56
+ private readonly props;
57
+ private seen;
58
+ constructor(props: Props);
59
+ has(id: string, updatedAt: string): boolean;
60
+ add(id: string, updatedAt: string): void;
61
+ trim(): void;
62
+ get size(): number;
63
+ }
64
+ //#endregion
65
+ export { FlumeGitHubNotificationSchema, FlumeGitHubPoller, FlumeGitHubSeenCache, FlumeGitHubSource, extractGitHubMeta };