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