@interactive-inc/flume 0.3.0 → 0.6.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,50 +1,8 @@
1
- import { n as createFlumeDefaultDeps, t as FlumeLogger } from "./logger-CpGB9WO_.js";
2
- import { i as FlumeConnectionError, n as resolveFlumeReconnectConfig, r as FlumeParseError, t as FlumeReconnector } from "./reconnector-BDoJ1xNX.js";
3
- import { t as FlumeSerialQueue } from "./serial-queue-ExmlnpzQ.js";
4
- import { n as isRecord, r as scheduleFlumeReconnect, t as safeJsonParse } from "./safe-json-parse-BWlzGOLl.js";
1
+ import { a as FlumeStartError, c as safeNormalizeError, i as safeNow, l as safeErrorMessage, n as safeInvokeCallback, o as FlumeParseError, r as FlumeLogger, s as attempt, t as FlumeSource } from "./flume-source-DUvt9aJt.js";
2
+ import { t as FlumeConnectionError } from "./connection-error-HUO3PC3G.js";
3
+ import { a as safeRandom, i as FlumeReconnector, n as isRecord, r as scheduleFlumeReconnect, t as safeStringify } from "./safe-stringify-DbWQw9qe.js";
4
+ import { t as safeJsonParse } from "./safe-json-parse-CfJjt-RY.js";
5
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
6
  //#region lib/discord/discord-gateway-session.ts
49
7
  var FlumeDiscordGatewaySession = class FlumeDiscordGatewaySession {
50
8
  sessionId;
@@ -85,27 +43,116 @@ var FlumeDiscordGatewaySession = class FlumeDiscordGatewaySession {
85
43
  }
86
44
  };
87
45
  //#endregion
46
+ //#region lib/discord/discord-heartbeat.ts
47
+ /**
48
+ * Discord Gateway のハートビート。
49
+ * 初回送信は `random()*heartbeat_interval` ms 後 (spec)、以降は heartbeat_interval ごと。
50
+ * 前回送信に対する ACK が来ていなければ zombie 通知。すべての callback / timer body は throw を
51
+ * 飲み込みログ記録する (host の timer queue に uncaught を漏らさない)
52
+ */
53
+ var FlumeDiscordHeartbeat = class {
54
+ props;
55
+ initialTimer = null;
56
+ intervalTimer = null;
57
+ ackReceived = true;
58
+ constructor(props) {
59
+ this.props = props;
60
+ }
61
+ start(intervalMs) {
62
+ this.stop();
63
+ this.ackReceived = true;
64
+ const initialDelay = safeRandom({ deps: this.props.deps }) * intervalMs;
65
+ const initialResult = attempt(() => this.props.deps.setTimeout(() => {
66
+ this.initialTimer = null;
67
+ this.safeFire();
68
+ const intervalResult = attempt(() => this.props.deps.setInterval(() => this.safeFire(), intervalMs));
69
+ if (intervalResult instanceof Error) {
70
+ this.props.log.error({
71
+ action: "heartbeat.interval.schedule.error",
72
+ message: safeErrorMessage({ error: intervalResult }),
73
+ error: intervalResult
74
+ });
75
+ this.intervalTimer = null;
76
+ } else this.intervalTimer = intervalResult;
77
+ }, initialDelay));
78
+ if (initialResult instanceof Error) {
79
+ this.props.log.error({
80
+ action: "heartbeat.initial.schedule.error",
81
+ message: safeErrorMessage({ error: initialResult }),
82
+ error: initialResult
83
+ });
84
+ this.initialTimer = null;
85
+ } else this.initialTimer = initialResult;
86
+ }
87
+ stop() {
88
+ if (this.initialTimer !== null) {
89
+ const handle = this.initialTimer;
90
+ const r = attempt(() => this.props.deps.clearTimeout(handle));
91
+ if (r instanceof Error) this.props.log.error({
92
+ action: "heartbeat.initial.clear.error",
93
+ message: safeErrorMessage({ error: r }),
94
+ error: r
95
+ });
96
+ this.initialTimer = null;
97
+ }
98
+ if (this.intervalTimer !== null) {
99
+ const handle = this.intervalTimer;
100
+ const r = attempt(() => this.props.deps.clearInterval(handle));
101
+ if (r instanceof Error) this.props.log.error({
102
+ action: "heartbeat.interval.clear.error",
103
+ message: safeErrorMessage({ error: r }),
104
+ error: r
105
+ });
106
+ this.intervalTimer = null;
107
+ }
108
+ }
109
+ ack() {
110
+ this.ackReceived = true;
111
+ }
112
+ isRunning() {
113
+ return this.initialTimer !== null || this.intervalTimer !== null;
114
+ }
115
+ safeFire() {
116
+ safeInvokeCallback({
117
+ fn: () => this.fire(),
118
+ onError: (error) => {
119
+ this.props.log.error({
120
+ action: "heartbeat.fire.error",
121
+ message: safeErrorMessage({ error }),
122
+ error
123
+ });
124
+ }
125
+ });
126
+ }
127
+ fire() {
128
+ if (!this.ackReceived) {
129
+ this.props.onZombie();
130
+ return;
131
+ }
132
+ this.ackReceived = false;
133
+ this.props.onSend();
134
+ }
135
+ };
136
+ //#endregion
88
137
  //#region lib/discord/discord-gateway-message-schema.ts
89
138
  const FlumeGatewayMessageSchema = z.object({
90
139
  op: z.number(),
91
- d: z.record(z.string(), z.unknown()).nullable(),
140
+ d: z.unknown(),
92
141
  s: z.number().nullable(),
93
142
  t: z.string().nullable()
94
143
  });
95
144
  //#endregion
96
145
  //#region lib/discord/parse-discord-gateway-message.ts
97
- function parseDiscordGatewayMessage(raw) {
146
+ function parseFlumeDiscordGatewayMessage(raw) {
98
147
  const json = safeJsonParse(raw);
148
+ if (json instanceof FlumeParseError) return json;
99
149
  const parsed = FlumeGatewayMessageSchema.safeParse(json);
100
- if (!parsed.success) return new FlumeParseError(`invalid gateway message: ${raw.slice(0, 200)}`);
150
+ if (!parsed.success) return new FlumeParseError(`invalid gateway message frame (${raw.length} bytes)`, { cause: parsed.error });
101
151
  return parsed.data;
102
152
  }
103
153
  //#endregion
104
154
  //#region lib/discord/discord-gateway.ts
105
155
  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
156
  const OP_DISPATCH = 0;
110
157
  const OP_HEARTBEAT = 1;
111
158
  const OP_IDENTIFY = 2;
@@ -124,15 +171,33 @@ const OP_NAMES = {
124
171
  [OP_HELLO]: "HELLO",
125
172
  [OP_HEARTBEAT_ACK]: "HEARTBEAT_ACK"
126
173
  };
174
+ const WS_OPEN = 1;
175
+ const TERMINAL_CLOSE_CODES = new Set([
176
+ 4004,
177
+ 4010,
178
+ 4011,
179
+ 4012,
180
+ 4013,
181
+ 4014
182
+ ]);
183
+ /**
184
+ * Discord Gateway v10 の最小実装。HELLO -> IDENTIFY/RESUME -> READY/RESUMED -> dispatch を扱う。
185
+ * READY 後の WebSocket 切断のみ `onStatus("disconnected")` を発火し source 側で再接続する。
186
+ * 終端 close code (4004 / 401x) を受けた場合は stopped 化して再接続を抑止。
187
+ * IO 境界は全て `attempt` 経由で扱い、コンストラクタ throw も `FlumeConnectionError` として返す
188
+ * (`connect()` は決して reject しない)
189
+ */
127
190
  var FlumeDiscordGateway = class {
128
191
  props;
129
192
  log;
130
193
  ws = null;
131
194
  heartbeat = null;
132
- session = FlumeDiscordGatewaySession.empty();
133
- stopped = false;
195
+ currentSession = FlumeDiscordGatewaySession.empty();
196
+ isStoppedFlag = false;
197
+ hasConnected = false;
134
198
  pendingResolve = null;
135
199
  pendingResolved = false;
200
+ invalidSessionTimer = null;
136
201
  constructor(props) {
137
202
  this.props = props;
138
203
  this.log = new FlumeLogger({
@@ -141,20 +206,65 @@ var FlumeDiscordGateway = class {
141
206
  deps: props.deps
142
207
  });
143
208
  }
209
+ get session() {
210
+ return this.currentSession;
211
+ }
212
+ get isStopped() {
213
+ return this.isStoppedFlag;
214
+ }
144
215
  connect(url) {
216
+ const WS = this.props.deps.WebSocket;
217
+ if (!WS) {
218
+ const error = new FlumeConnectionError("WebSocket runtime not available");
219
+ this.log.error({
220
+ action: "ws.error",
221
+ message: safeErrorMessage({ error }),
222
+ error
223
+ });
224
+ return Promise.resolve(error);
225
+ }
145
226
  const target = url ?? GATEWAY_URL;
227
+ const hostResult = attempt(() => new URL(target).hostname);
228
+ const host = hostResult instanceof Error ? "unknown" : hostResult;
146
229
  this.log.info({
147
230
  action: "connect.start",
148
- message: `url=${new URL(target).hostname}`
231
+ message: `host=${host}`
149
232
  });
150
233
  this.pendingResolved = false;
234
+ this.hasConnected = false;
151
235
  return new Promise((resolve) => {
152
236
  this.pendingResolve = resolve;
153
- const socket = new this.props.deps.WebSocket(target);
237
+ const socketResult = attempt(() => new WS(target));
238
+ if (socketResult instanceof Error) {
239
+ const error = new FlumeConnectionError(`WebSocket construction failed: ${safeErrorMessage({ error: socketResult })}`, { cause: socketResult });
240
+ this.log.error({
241
+ action: "ws.construct.error",
242
+ message: safeErrorMessage({ error }),
243
+ error
244
+ });
245
+ this.ws = null;
246
+ this.pendingResolved = true;
247
+ resolve(error);
248
+ return;
249
+ }
250
+ const socket = socketResult;
154
251
  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());
252
+ const listenerResult = attempt(() => {
253
+ socket.addEventListener("message", (ev) => this.safeOnMessage(ev, socket));
254
+ socket.addEventListener("close", (ev) => this.safeOnClose(ev));
255
+ socket.addEventListener("error", () => this.safeOnError());
256
+ });
257
+ if (listenerResult instanceof Error) {
258
+ const error = new FlumeConnectionError(`WebSocket listener registration failed: ${safeErrorMessage({ error: listenerResult })}`, { cause: listenerResult });
259
+ this.log.error({
260
+ action: "ws.listener.error",
261
+ message: safeErrorMessage({ error }),
262
+ error
263
+ });
264
+ this.ws = null;
265
+ this.pendingResolved = true;
266
+ resolve(error);
267
+ }
158
268
  });
159
269
  }
160
270
  disconnect() {
@@ -162,45 +272,71 @@ var FlumeDiscordGateway = class {
162
272
  action: "disconnect",
163
273
  message: "shutting down gateway"
164
274
  });
165
- this.stopped = true;
275
+ this.isStoppedFlag = true;
166
276
  this.heartbeat?.stop();
167
- if (this.ws) {
168
- this.ws.close(1e3, "shutdown");
169
- this.ws = null;
170
- }
277
+ this.clearInvalidSessionTimer();
278
+ this.closeSocket({
279
+ ws: this.ws,
280
+ code: 1e3,
281
+ reason: "shutdown"
282
+ });
283
+ this.ws = null;
171
284
  }
172
285
  isConnected() {
173
- return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
286
+ return this.ws !== null && this.ws.readyState === WS_OPEN;
174
287
  }
175
288
  completeConnect(error) {
176
289
  if (this.pendingResolved || !this.pendingResolve) return;
177
290
  this.pendingResolved = true;
178
291
  this.pendingResolve(error);
179
292
  }
180
- onMessage(raw, socket) {
181
- this.log.debug({
182
- action: "ws.recv",
183
- message: framePreview(raw)
293
+ safeOnMessage(ev, socket) {
294
+ const r = attempt(() => this.onMessage(String(ev.data), socket));
295
+ if (r instanceof Error) this.log.error({
296
+ action: "ws.message.threw",
297
+ message: safeErrorMessage({ error: r }),
298
+ error: r
299
+ });
300
+ }
301
+ safeOnClose(ev) {
302
+ const r = attempt(() => this.onClose(ev));
303
+ if (r instanceof Error) this.log.error({
304
+ action: "ws.close.threw",
305
+ message: safeErrorMessage({ error: r }),
306
+ error: r
307
+ });
308
+ }
309
+ safeOnError() {
310
+ const r = attempt(() => this.onError());
311
+ if (r instanceof Error) this.log.error({
312
+ action: "ws.error.threw",
313
+ message: safeErrorMessage({ error: r }),
314
+ error: r
184
315
  });
185
- const parsed = parseDiscordGatewayMessage(raw);
316
+ }
317
+ onMessage(raw, socket) {
318
+ if (this.isStoppedFlag) return;
319
+ const parsed = parseFlumeDiscordGatewayMessage(raw);
186
320
  if (parsed instanceof FlumeParseError) {
187
321
  this.log.error({
188
- action: "ws.parse-error",
322
+ action: "ws.parse.error",
189
323
  message: parsed.message,
190
- error: parsed
324
+ error: parsed,
325
+ detail: { length: raw.length }
191
326
  });
192
327
  return;
193
328
  }
194
329
  this.log.debug({
195
- action: "ws.frame",
196
- message: `op=${OP_NAMES[parsed.op] ?? parsed.op} t=${parsed.t ?? "-"} s=${parsed.s ?? "-"}`,
330
+ action: "ws.recv",
331
+ message: `op=${OP_NAMES[parsed.op] ?? parsed.op} t=${parsed.t ?? "-"} s=${parsed.s ?? "-"} length=${raw.length}`,
197
332
  detail: {
198
333
  op: parsed.op,
199
334
  t: parsed.t,
200
- s: parsed.s
335
+ s: parsed.s,
336
+ length: raw.length
201
337
  }
202
338
  });
203
- if (parsed.s !== null) this.session = this.session.withSeq(parsed.s);
339
+ if (parsed.s !== null) this.currentSession = this.currentSession.withSeq(parsed.s);
204
340
  if (parsed.op === OP_HELLO) return this.onHello(parsed);
205
341
  if (parsed.op === OP_HEARTBEAT_ACK) return this.onHeartbeatAck();
206
342
  if (parsed.op === OP_HEARTBEAT) return this.onHeartbeatRequest();
@@ -208,27 +344,31 @@ var FlumeDiscordGateway = class {
208
344
  if (parsed.op === OP_INVALID_SESSION) return this.onInvalidSession(parsed, socket);
209
345
  if (parsed.op === OP_DISPATCH) return this.onDispatch(parsed);
210
346
  this.log.warn({
211
- action: "ws.unknown-op",
347
+ action: "ws.op.unknown",
212
348
  message: `unknown op=${parsed.op}`,
213
349
  detail: { op: parsed.op }
214
350
  });
215
351
  }
216
352
  onHello(msg) {
217
- const interval = typeof msg.d?.heartbeat_interval === "number" ? msg.d.heartbeat_interval : 0;
353
+ const d = isRecord(msg.d) ? msg.d : null;
354
+ const interval = d && typeof d.heartbeat_interval === "number" ? d.heartbeat_interval : 0;
218
355
  this.log.info({
219
- action: "hello",
220
- message: `heartbeat_interval=${interval}ms`
356
+ action: "gateway.hello",
357
+ message: `heartbeat_interval=${interval}ms`,
358
+ detail: { interval }
221
359
  });
360
+ this.heartbeat?.stop();
222
361
  this.heartbeat = new FlumeDiscordHeartbeat({
362
+ log: this.log,
223
363
  deps: this.props.deps,
224
364
  onSend: () => {
225
365
  this.log.debug({
226
366
  action: "heartbeat.send",
227
- message: `seq=${this.session.seq}`
367
+ message: `seq=${this.currentSession.seq}`
228
368
  });
229
369
  this.send({
230
370
  op: OP_HEARTBEAT,
231
- d: this.session.seq
371
+ d: this.currentSession.seq
232
372
  });
233
373
  },
234
374
  onZombie: () => {
@@ -236,11 +376,15 @@ var FlumeDiscordGateway = class {
236
376
  action: "heartbeat.zombie",
237
377
  message: "no ACK received, closing connection"
238
378
  });
239
- this.ws?.close(4009, "zombie connection");
379
+ this.closeSocket({
380
+ ws: this.ws,
381
+ code: 4009,
382
+ reason: "zombie connection"
383
+ });
240
384
  }
241
385
  });
242
386
  this.heartbeat.start(interval);
243
- if (this.session.canResume()) this.sendResume();
387
+ if (this.currentSession.canResume()) this.sendResume();
244
388
  else this.sendIdentify();
245
389
  }
246
390
  onHeartbeatAck() {
@@ -257,95 +401,185 @@ var FlumeDiscordGateway = class {
257
401
  });
258
402
  this.send({
259
403
  op: OP_HEARTBEAT,
260
- d: this.session.seq
404
+ d: this.currentSession.seq
261
405
  });
262
406
  }
263
407
  onReconnectRequest(socket) {
264
408
  this.log.info({
265
- action: "reconnect.requested",
409
+ action: "ws.reconnect.requested",
266
410
  message: "server requested reconnect"
267
411
  });
268
- socket.close(4e3, "reconnect requested");
412
+ this.closeSocket({
413
+ ws: socket,
414
+ code: 4e3,
415
+ reason: "reconnect requested"
416
+ });
269
417
  }
270
418
  onInvalidSession(msg, socket) {
271
- const resumable = !!msg.d;
419
+ const resumable = msg.d === true || isRecord(msg.d) && msg.d.resumable === true;
272
420
  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`
421
+ action: "session.invalid",
422
+ message: `resumable=${resumable}`,
423
+ detail: { resumable }
424
+ });
425
+ const delay = 1e3 + safeRandom({ deps: this.props.deps }) * 4e3;
426
+ if (!resumable) this.currentSession = this.currentSession.withReset();
427
+ this.clearInvalidSessionTimer();
428
+ const timerResult = attempt(() => this.props.deps.setTimeout(() => {
429
+ this.invalidSessionTimer = null;
430
+ this.closeSocket({
431
+ ws: socket,
432
+ code: 4e3,
433
+ reason: "invalid session"
282
434
  });
283
- this.props.deps.setTimeout(() => this.sendIdentify(), delay);
284
- } else socket.close(4e3, "invalid session");
435
+ }, delay));
436
+ if (timerResult instanceof Error) {
437
+ this.log.error({
438
+ action: "session.invalid.timer.error",
439
+ message: safeErrorMessage({ error: timerResult }),
440
+ error: timerResult
441
+ });
442
+ this.invalidSessionTimer = null;
443
+ } else this.invalidSessionTimer = timerResult;
285
444
  }
286
445
  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);
446
+ const d = isRecord(msg.d) ? msg.d : null;
447
+ if (msg.t === "READY" && d) {
448
+ const sessionId = typeof d.session_id === "string" ? d.session_id : "";
449
+ const resumeUrl = typeof d.resume_gateway_url === "string" ? d.resume_gateway_url : "";
450
+ this.currentSession = this.currentSession.withReady(sessionId, resumeUrl);
291
451
  this.log.info({
292
- action: "ready",
293
- message: `session=${sessionId}`
452
+ action: "gateway.ready",
453
+ message: `session ready`,
454
+ detail: { hasResumeUrl: resumeUrl !== "" }
294
455
  });
456
+ this.hasConnected = true;
295
457
  this.props.onStatus("connected");
296
458
  this.completeConnect(null);
297
459
  }
298
460
  if (msg.t === "RESUMED") {
299
461
  this.log.info({
300
- action: "resumed",
301
- message: `session=${this.session.sessionId} seq=${this.session.seq}`
462
+ action: "gateway.resumed",
463
+ message: `seq=${this.currentSession.seq}`
302
464
  });
465
+ this.hasConnected = true;
303
466
  this.props.onStatus("connected");
304
467
  this.completeConnect(null);
305
468
  }
306
- if (msg.t && msg.d) this.props.onDispatch(msg.t, msg.d);
307
- else if (msg.t) this.props.onDispatch(msg.t, {});
469
+ if (msg.t && d) this.props.onDispatch(msg.t, d);
470
+ else if (msg.t) this.log.debug({
471
+ action: "dispatch.empty",
472
+ message: `dropped ${msg.t} (no payload)`,
473
+ detail: { type: msg.t }
474
+ });
308
475
  }
309
476
  onClose(ev) {
477
+ const terminal = TERMINAL_CLOSE_CODES.has(ev.code);
310
478
  this.log.info({
311
479
  action: "ws.close",
312
- message: `code=${ev.code} reason=${ev.reason || "none"}`,
480
+ message: `code=${ev.code} reason=${ev.reason || "none"}${terminal ? " (terminal)" : ""}`,
313
481
  detail: {
314
482
  code: ev.code,
315
- reason: ev.reason
483
+ reason: ev.reason,
484
+ terminal
316
485
  }
317
486
  });
318
487
  this.ws = null;
319
488
  this.heartbeat?.stop();
320
- this.props.onStatus("disconnected");
321
- this.completeConnect(new FlumeConnectionError(`WebSocket closed before ready (code=${ev.code})`));
489
+ this.clearInvalidSessionTimer();
490
+ if (terminal) this.isStoppedFlag = true;
491
+ if (this.hasConnected || terminal) this.props.onStatus("disconnected");
492
+ if (!this.pendingResolved) {
493
+ const error = new FlumeConnectionError(`WebSocket closed before ready (code=${ev.code})`, { code: ev.code });
494
+ this.completeConnect(error);
495
+ }
322
496
  }
323
497
  onError() {
498
+ const error = new FlumeConnectionError("WebSocket connection error");
324
499
  this.log.error({
325
500
  action: "ws.error",
326
- message: "WebSocket error event"
501
+ message: safeErrorMessage({ error }),
502
+ error
503
+ });
504
+ this.completeConnect(error);
505
+ }
506
+ clearInvalidSessionTimer() {
507
+ if (this.invalidSessionTimer === null) return;
508
+ const handle = this.invalidSessionTimer;
509
+ const result = attempt(() => this.props.deps.clearTimeout(handle));
510
+ if (result instanceof Error) this.log.error({
511
+ action: "session.invalid.timer.clear.error",
512
+ message: safeErrorMessage({ error: result }),
513
+ error: result
327
514
  });
328
- this.completeConnect(new FlumeConnectionError("WebSocket connection error"));
515
+ this.invalidSessionTimer = null;
329
516
  }
330
- send(input) {
331
- const payload = JSON.stringify({
332
- op: input.op,
333
- d: input.d ?? null
517
+ closeSocket(input) {
518
+ if (input.ws === null) return;
519
+ const ws = input.ws;
520
+ const result = attempt(() => {
521
+ if (input.code !== void 0) ws.close(input.code, input.reason ?? "");
522
+ else ws.close();
523
+ });
524
+ if (result instanceof Error) this.log.error({
525
+ action: "ws.close.error",
526
+ message: safeErrorMessage({ error: result }),
527
+ error: result
334
528
  });
529
+ }
530
+ send(input) {
531
+ const payload = this.safeSerialize(input);
532
+ if (payload === null) return;
335
533
  this.log.debug({
336
534
  action: "ws.send",
337
- message: `op=${OP_NAMES[input.op] ?? input.op}`,
338
- detail: { op: input.op }
535
+ message: `op=${OP_NAMES[input.op] ?? input.op} length=${payload.length}`,
536
+ detail: {
537
+ op: input.op,
538
+ length: payload.length
539
+ }
339
540
  });
340
- this.ws?.send(payload);
341
- this.log.debug({
342
- action: "ws.sent",
343
- message: framePreview(payload)
541
+ const ws = this.ws;
542
+ if (ws === null) {
543
+ this.log.warn({
544
+ action: "ws.send",
545
+ message: "ws.send skipped: socket is null"
546
+ });
547
+ return;
548
+ }
549
+ if (ws.readyState !== WS_OPEN) {
550
+ this.log.warn({
551
+ action: "ws.send",
552
+ message: `ws.send skipped: readyState=${ws.readyState} (not OPEN)`,
553
+ detail: { readyState: ws.readyState }
554
+ });
555
+ return;
556
+ }
557
+ const result = attempt(() => ws.send(payload));
558
+ if (result instanceof Error) this.log.error({
559
+ action: "ws.send",
560
+ message: `ws.send failed: ${safeErrorMessage({ error: result })}`,
561
+ error: result
562
+ });
563
+ }
564
+ safeSerialize(input) {
565
+ const result = safeStringify({
566
+ op: input.op,
567
+ d: input.d ?? null
344
568
  });
569
+ if (result instanceof Error) {
570
+ this.log.error({
571
+ action: "ws.send.serialize.error",
572
+ message: safeErrorMessage({ error: result }),
573
+ error: result,
574
+ detail: { op: input.op }
575
+ });
576
+ return null;
577
+ }
578
+ return result;
345
579
  }
346
580
  sendIdentify() {
347
581
  this.log.info({
348
- action: "identify",
582
+ action: "gateway.identify",
349
583
  message: `intents=${this.props.intents}`
350
584
  });
351
585
  this.send({
@@ -363,15 +597,15 @@ var FlumeDiscordGateway = class {
363
597
  }
364
598
  sendResume() {
365
599
  this.log.info({
366
- action: "resume",
367
- message: `session=${this.session.sessionId} seq=${this.session.seq}`
600
+ action: "gateway.resume",
601
+ message: `seq=${this.currentSession.seq}`
368
602
  });
369
603
  this.send({
370
604
  op: OP_RESUME,
371
605
  d: {
372
606
  token: this.props.token,
373
- session_id: this.session.sessionId,
374
- seq: this.session.seq
607
+ session_id: this.currentSession.sessionId,
608
+ seq: this.currentSession.seq
375
609
  }
376
610
  });
377
611
  }
@@ -402,145 +636,140 @@ const FlumeDiscordGatewayIntents = {
402
636
  DirectMessagePolls: 1 << 25
403
637
  };
404
638
  //#endregion
639
+ //#region lib/discord/extract-discord-meta.ts
640
+ function flumeExtractDiscordMeta(eventName, eventData) {
641
+ const meta = { event_type: eventName };
642
+ if (typeof eventData.channel_id === "string") meta.channel_id = eventData.channel_id;
643
+ if (typeof eventData.guild_id === "string") meta.guild_id = eventData.guild_id;
644
+ if (isRecord(eventData.author) && typeof eventData.author.id === "string") meta.user_id = eventData.author.id;
645
+ return meta;
646
+ }
647
+ //#endregion
405
648
  //#region lib/discord/discord-source.ts
406
- const DEFAULT_INTENTS = FlumeDiscordGatewayIntents.Guilds | FlumeDiscordGatewayIntents.GuildMessages | FlumeDiscordGatewayIntents.DirectMessages | FlumeDiscordGatewayIntents.MessageContent;
407
- var FlumeDiscordSource = class {
649
+ const DEFAULT_INTENTS = FlumeDiscordGatewayIntents.Guilds | FlumeDiscordGatewayIntents.GuildMessages | FlumeDiscordGatewayIntents.DirectMessages;
650
+ var FlumeDiscordSource = class extends FlumeSource {
408
651
  options;
409
652
  name = "discord";
410
653
  gateway = null;
411
654
  reconnector = null;
412
- handler = null;
413
- currentStatus = "disconnected";
414
- log;
415
- deps;
416
- queue = new FlumeSerialQueue();
417
655
  constructor(options) {
656
+ super();
418
657
  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 {
433
- ok: false,
434
- error: /* @__PURE__ */ new Error("Discord source: signal already aborted")
435
- };
436
- this.options.signal?.addEventListener("abort", () => this.stop(), { once: true });
437
- this.handler = handler;
438
- this.log.info({
439
- action: "start",
440
- message: "starting Discord source"
441
- });
442
- const result = await this.connectInternal();
443
- if (result instanceof Error) return {
444
- ok: false,
445
- error: result
446
- };
447
- return { ok: true };
448
658
  }
449
- async stop() {
450
- this.log.info({
451
- action: "stop",
452
- message: "stopping Discord source"
659
+ async connect(ctx) {
660
+ if (!this.hasWebSocket(ctx)) return new FlumeStartError("Discord source: deps.WebSocket is null (no WebSocket runtime available)");
661
+ if (ctx.reconnect && !this.reconnector) this.reconnector = new FlumeReconnector({
662
+ ...ctx.reconnect,
663
+ log: ctx.log,
664
+ deps: ctx.deps
453
665
  });
454
- if (this.reconnector && !this.reconnector.aborted) this.log.debug({
666
+ return await this.connectInternal(ctx);
667
+ }
668
+ disconnect() {
669
+ if (this.reconnector && !this.reconnector.aborted) this.context?.log.debug({
455
670
  action: "reconnect.cancel",
456
671
  message: "aborting reconnector"
457
672
  });
458
673
  this.reconnector?.cancel();
459
674
  this.gateway?.disconnect();
460
675
  this.gateway = null;
461
- this.handler = null;
462
- await this.queue.drain();
463
- this.setStatus("disconnected");
464
676
  }
465
- status() {
466
- return this.currentStatus;
677
+ hasWebSocket(ctx) {
678
+ const result = attempt(() => Boolean(ctx.deps.WebSocket));
679
+ if (result instanceof Error) {
680
+ const error = safeNormalizeError({ value: result });
681
+ ctx.log.error({
682
+ action: "deps.web-socket.read.error",
683
+ message: safeErrorMessage({ error }),
684
+ error
685
+ });
686
+ return false;
687
+ }
688
+ return result;
467
689
  }
468
- async connectInternal(resumeUrl) {
690
+ async connectInternal(ctx, resumeUrl) {
469
691
  this.setStatus("connecting");
470
692
  this.gateway = new FlumeDiscordGateway({
471
693
  token: this.options.token,
472
694
  intents: this.options.intents ?? DEFAULT_INTENTS,
473
- onLog: this.options.onLog,
474
- deps: this.deps,
475
- onDispatch: (eventName, eventData) => this.handleDispatch(eventName, eventData),
476
- onStatus: (status) => this.handleGatewayStatus(status)
695
+ onLog: ctx.log.handler,
696
+ deps: ctx.deps,
697
+ onDispatch: (eventName, eventData) => this.dispatch(ctx, eventName, eventData),
698
+ onStatus: (status) => this.handleGatewayStatus(ctx, status)
477
699
  });
478
700
  const error = await this.gateway.connect(resumeUrl);
479
701
  if (error instanceof FlumeConnectionError) {
480
- this.log.error({
702
+ ctx.log.error({
481
703
  action: "connect.failed",
482
- message: error.message,
704
+ message: safeErrorMessage({ error }),
483
705
  error
484
706
  });
485
- if (!this.reconnector || this.reconnector.aborted) {
707
+ if (this.gateway.isStopped || !this.reconnector || this.reconnector.aborted) {
486
708
  this.setStatus("disconnected");
487
709
  return error;
488
710
  }
489
- this.scheduleReconnect();
711
+ this.scheduleReconnect(ctx);
490
712
  }
491
713
  return null;
492
714
  }
493
- handleDispatch(eventName, eventData) {
494
- const event = {
715
+ dispatch(ctx, eventName, eventData) {
716
+ this.emit({
495
717
  source: "discord",
496
718
  type: eventName,
497
719
  data: eventData,
498
- meta: extractDiscordMeta(eventName, eventData),
499
- receivedAt: this.deps.now()
500
- };
501
- this.queue.add(async () => {
502
- try {
503
- await this.handler?.(event);
504
- } catch (err) {
505
- this.log.error({
506
- action: "handler.error",
507
- message: "user handler threw",
508
- error: err instanceof Error ? err : new Error(String(err))
509
- });
510
- }
511
- });
720
+ meta: this.safeExtractMeta(ctx, eventName, eventData),
721
+ receivedAt: safeNow({ deps: ctx.deps })
722
+ });
723
+ }
724
+ safeExtractMeta(ctx, eventName, eventData) {
725
+ const result = attempt(() => flumeExtractDiscordMeta(eventName, eventData));
726
+ if (result instanceof Error) {
727
+ const error = safeNormalizeError({ value: result });
728
+ ctx.log.warn({
729
+ action: "meta.extract.error",
730
+ message: safeErrorMessage({ error }),
731
+ error,
732
+ detail: { eventName }
733
+ });
734
+ return { event_type: eventName };
735
+ }
736
+ return result;
512
737
  }
513
- handleGatewayStatus(status) {
738
+ handleGatewayStatus(ctx, status) {
514
739
  if (status === "connected") {
515
- if (this.reconnector && this.reconnector.attempt > 0) this.log.info({
740
+ if (this.reconnector && this.reconnector.attempt > 0) ctx.log.info({
516
741
  action: "reconnect.reset",
517
742
  message: `cleared ${this.reconnector.attempt} attempts`
518
743
  });
519
744
  this.reconnector?.reset();
520
745
  this.setStatus("connected");
746
+ return;
747
+ }
748
+ if (this.gateway?.isStopped) {
749
+ this.setStatus("disconnected");
750
+ return;
521
751
  }
522
- if (status === "disconnected" && !this.gateway?.stopped) this.scheduleReconnect();
752
+ this.scheduleReconnect(ctx);
523
753
  }
524
- scheduleReconnect() {
754
+ scheduleReconnect(ctx) {
525
755
  const url = this.gateway?.session.resumeUrl ?? void 0;
526
756
  scheduleFlumeReconnect({
527
757
  reconnector: this.reconnector,
528
- log: this.log,
758
+ log: ctx.log,
529
759
  setStatus: (status) => this.setStatus(status),
530
760
  retry: () => {
531
- this.connectInternal(url);
761
+ this.connectInternal(ctx, url).catch((err) => {
762
+ const error = safeNormalizeError({ value: err });
763
+ ctx.log.error({
764
+ action: "reconnect.unhandled",
765
+ message: safeErrorMessage({ error }),
766
+ error
767
+ });
768
+ this.setStatus("disconnected");
769
+ });
532
770
  }
533
771
  });
534
772
  }
535
- setStatus(next) {
536
- if (this.currentStatus === next) return;
537
- this.log.info({
538
- action: "status",
539
- message: `${this.currentStatus} → ${next}`
540
- });
541
- this.currentStatus = next;
542
- this.options.onStatus?.(next);
543
- }
544
773
  };
545
774
  //#endregion
546
- export { FlumeDiscordGateway, FlumeDiscordGatewayIntents, FlumeDiscordGatewaySession, FlumeDiscordHeartbeat, FlumeDiscordSource, FlumeGatewayMessageSchema, extractDiscordMeta, parseDiscordGatewayMessage };
775
+ export { FlumeDiscordGatewayIntents, FlumeDiscordSource, flumeExtractDiscordMeta };