@interactive-inc/flume 0.2.0 → 0.4.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 { 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";
1
+ import { a as FlumeParseError, c as safeNormalizeError, i as FlumeStartError, l as safeErrorMessage, n as FlumeLogger, o as createFlumeDefaultDeps, r as safeNow, s as attempt, t as safeInvokeCallback } from "./safe-invoke-callback-EpWXwfwp.js";
2
+ import { t as FlumeConnectionError } from "./connection-error-HUO3PC3G.js";
3
+ import { a as FlumeReconnector, i as resolveFlumeReconnectConfig, n as isRecord, o as safeRandom, r as scheduleFlumeReconnect, t as safeStringify } from "./safe-stringify-BWS-uXZP.js";
4
+ import { i as safeJsonParse, n as FlumeStatusEmitter, r as FlumeSignalRegistry, t as FlumeSerialQueue } from "./serial-queue-B9LoBc64.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
184
307
  });
185
- const parsed = parseDiscordGatewayMessage(raw);
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
315
+ });
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"
434
+ });
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
282
441
  });
283
- this.props.deps.setTimeout(() => this.sendIdentify(), delay);
284
- } else socket.close(4e3, "invalid session");
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
327
503
  });
328
- this.completeConnect(new FlumeConnectionError("WebSocket connection error"));
504
+ this.completeConnect(error);
329
505
  }
330
- send(input) {
331
- const payload = JSON.stringify({
332
- op: input.op,
333
- d: input.d ?? null
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
334
514
  });
515
+ this.invalidSessionTimer = null;
516
+ }
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
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
344
562
  });
345
563
  }
564
+ safeSerialize(input) {
565
+ const result = safeStringify({
566
+ op: input.op,
567
+ d: input.d ?? null
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;
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,18 +636,40 @@ 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;
649
+ const DEFAULT_INTENTS = FlumeDiscordGatewayIntents.Guilds | FlumeDiscordGatewayIntents.GuildMessages | FlumeDiscordGatewayIntents.DirectMessages;
407
650
  var FlumeDiscordSource = class {
408
651
  options;
409
652
  name = "discord";
410
653
  gateway = null;
411
654
  reconnector = null;
412
655
  handler = null;
413
- currentStatus = "disconnected";
414
656
  log;
415
657
  deps;
416
658
  queue = new FlumeSerialQueue();
659
+ signals;
660
+ statusEmitter;
661
+ onSignalAbort = () => {
662
+ safeInvokeCallback({
663
+ fn: () => this.stop(),
664
+ onError: (error) => {
665
+ this.log.error({
666
+ action: "signal.abort.stop.failed",
667
+ message: safeErrorMessage({ error }),
668
+ error
669
+ });
670
+ }
671
+ });
672
+ };
417
673
  constructor(options) {
418
674
  this.options = options;
419
675
  this.deps = options.deps ?? createFlumeDefaultDeps();
@@ -422,25 +678,37 @@ var FlumeDiscordSource = class {
422
678
  handler: options.onLog,
423
679
  deps: this.deps
424
680
  });
681
+ this.signals = new FlumeSignalRegistry({
682
+ log: this.log,
683
+ onAbort: this.onSignalAbort
684
+ });
685
+ this.statusEmitter = new FlumeStatusEmitter({
686
+ log: this.log,
687
+ onStatus: options.onStatus
688
+ });
425
689
  const rc = resolveFlumeReconnectConfig(options.reconnect);
426
690
  if (rc) this.reconnector = new FlumeReconnector({
427
691
  ...rc,
692
+ log: this.log,
428
693
  deps: this.deps
429
694
  });
430
695
  }
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 });
696
+ async start(handler, options) {
697
+ if (this.signals.isAnyAborted(this.options.signal) || this.signals.isAnyAborted(options?.signal)) return new FlumeStartError("Discord source: signal already aborted");
698
+ if (!this.hasWebSocket()) return new FlumeStartError("Discord source: deps.WebSocket is null (no WebSocket runtime available)");
699
+ this.signals.register(this.options.signal);
700
+ this.signals.register(options?.signal);
434
701
  this.handler = handler;
435
702
  this.log.info({
436
- action: "start",
703
+ action: "source.start",
437
704
  message: "starting Discord source"
438
705
  });
439
- return this.connectInternal();
706
+ return await this.connectInternal();
440
707
  }
441
708
  async stop() {
709
+ this.signals.unregisterAll();
442
710
  this.log.info({
443
- action: "stop",
711
+ action: "source.stop",
444
712
  message: "stopping Discord source"
445
713
  });
446
714
  if (this.reconnector && !this.reconnector.aborted) this.log.debug({
@@ -449,16 +717,29 @@ var FlumeDiscordSource = class {
449
717
  });
450
718
  this.reconnector?.cancel();
451
719
  this.gateway?.disconnect();
720
+ await this.queue.drain();
452
721
  this.gateway = null;
453
722
  this.handler = null;
454
- await this.queue.drain();
455
- this.setStatus("disconnected");
723
+ this.statusEmitter.set("disconnected");
456
724
  }
457
725
  status() {
458
- return this.currentStatus;
726
+ return this.statusEmitter.value;
727
+ }
728
+ hasWebSocket() {
729
+ const result = attempt(() => Boolean(this.deps.WebSocket));
730
+ if (result instanceof Error) {
731
+ const error = safeNormalizeError({ value: result });
732
+ this.log.error({
733
+ action: "deps.web-socket.read.error",
734
+ message: safeErrorMessage({ error }),
735
+ error
736
+ });
737
+ return false;
738
+ }
739
+ return result;
459
740
  }
460
741
  async connectInternal(resumeUrl) {
461
- this.setStatus("connecting");
742
+ this.statusEmitter.set("connecting");
462
743
  this.gateway = new FlumeDiscordGateway({
463
744
  token: this.options.token,
464
745
  intents: this.options.intents ?? DEFAULT_INTENTS,
@@ -471,36 +752,50 @@ var FlumeDiscordSource = class {
471
752
  if (error instanceof FlumeConnectionError) {
472
753
  this.log.error({
473
754
  action: "connect.failed",
474
- message: error.message,
755
+ message: safeErrorMessage({ error }),
475
756
  error
476
757
  });
477
- if (!this.reconnector || this.reconnector.aborted) {
478
- this.setStatus("disconnected");
758
+ if (this.gateway.isStopped || !this.reconnector || this.reconnector.aborted) {
759
+ this.statusEmitter.set("disconnected");
479
760
  return error;
480
761
  }
481
762
  this.scheduleReconnect();
482
763
  }
764
+ return null;
483
765
  }
484
766
  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
- };
767
+ const handler = this.handler;
768
+ if (!handler) return;
492
769
  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
- }
770
+ const event = {
771
+ source: "discord",
772
+ type: eventName,
773
+ data: eventData,
774
+ meta: this.safeExtractMeta(eventName, eventData),
775
+ receivedAt: safeNow({ deps: this.deps })
776
+ };
777
+ const r = await attempt(() => Promise.resolve(handler(event)));
778
+ if (r instanceof Error) this.log.error({
779
+ action: "handler.error",
780
+ message: safeErrorMessage({ error: r }),
781
+ error: r
782
+ });
502
783
  });
503
784
  }
785
+ safeExtractMeta(eventName, eventData) {
786
+ const result = attempt(() => flumeExtractDiscordMeta(eventName, eventData));
787
+ if (result instanceof Error) {
788
+ const error = safeNormalizeError({ value: result });
789
+ this.log.warn({
790
+ action: "meta.extract.error",
791
+ message: safeErrorMessage({ error }),
792
+ error,
793
+ detail: { eventName }
794
+ });
795
+ return { event_type: eventName };
796
+ }
797
+ return result;
798
+ }
504
799
  handleGatewayStatus(status) {
505
800
  if (status === "connected") {
506
801
  if (this.reconnector && this.reconnector.attempt > 0) this.log.info({
@@ -508,30 +803,34 @@ var FlumeDiscordSource = class {
508
803
  message: `cleared ${this.reconnector.attempt} attempts`
509
804
  });
510
805
  this.reconnector?.reset();
511
- this.setStatus("connected");
806
+ this.statusEmitter.set("connected");
807
+ return;
808
+ }
809
+ if (this.gateway?.isStopped) {
810
+ this.statusEmitter.set("disconnected");
811
+ return;
512
812
  }
513
- if (status === "disconnected" && !this.gateway?.stopped) this.scheduleReconnect();
813
+ this.scheduleReconnect();
514
814
  }
515
815
  scheduleReconnect() {
516
816
  const url = this.gateway?.session.resumeUrl ?? void 0;
517
817
  scheduleFlumeReconnect({
518
818
  reconnector: this.reconnector,
519
819
  log: this.log,
520
- setStatus: (status) => this.setStatus(status),
820
+ setStatus: (status) => this.statusEmitter.set(status),
521
821
  retry: () => {
522
- this.connectInternal(url);
822
+ this.connectInternal(url).catch((err) => {
823
+ const error = safeNormalizeError({ value: err });
824
+ this.log.error({
825
+ action: "reconnect.unhandled",
826
+ message: safeErrorMessage({ error }),
827
+ error
828
+ });
829
+ this.statusEmitter.set("disconnected");
830
+ });
523
831
  }
524
832
  });
525
833
  }
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
834
  };
536
835
  //#endregion
537
- export { FlumeDiscordGateway, FlumeDiscordGatewayIntents, FlumeDiscordGatewaySession, FlumeDiscordHeartbeat, FlumeDiscordSource, FlumeGatewayMessageSchema, extractDiscordMeta, parseDiscordGatewayMessage };
836
+ export { FlumeDiscordGatewayIntents, FlumeDiscordSource, flumeExtractDiscordMeta };