@interactive-inc/flume 0.10.0 → 0.10.1

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/README.md CHANGED
@@ -168,9 +168,10 @@ class MyWebhookSource extends FlumeSource {
168
168
  const res = await ctx.deps.fetch(this.options.url)
169
169
  const payload = await res.json()
170
170
  this.emit({
171
- source: this.name as "discord", // declare your own discriminant via FlumeEvent extension
171
+ source: "custom",
172
+ sourceName: this.name,
172
173
  type: "webhook",
173
- data: payload,
174
+ data: { payload },
174
175
  meta: { event_type: "webhook" },
175
176
  receivedAt: ctx.deps.now(),
176
177
  })
package/dist/discord.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { D as FlumeSourceStartContext, o as FlumeDiscordSourceOptions, t as FlumeSource } from "./flume-source.js";
1
+ import { O as FlumeSourceStartContext, s as FlumeDiscordSourceOptions, t as FlumeSource } from "./flume-source.js";
2
2
 
3
3
  //#region lib/discord/discord-source.d.ts
4
4
  declare class FlumeDiscordSource extends FlumeSource {
@@ -10,10 +10,24 @@ declare class FlumeDiscordSource extends FlumeSource {
10
10
  protected connect(ctx: FlumeSourceStartContext): Promise<Error | null>;
11
11
  protected disconnect(): void;
12
12
  private hasWebSocket;
13
+ /**
14
+ * gateway を 1 接続 = 1 インスタンスで作り直す。`session` は前回接続から引き継いだ
15
+ * resume 可能な session (無ければ IDENTIFY)。await 後は `this.gateway` でなく local な
16
+ * `gateway` を参照する (並行する close() が `this.gateway` を null 化しても壊れない)
17
+ */
13
18
  private connectInternal;
14
19
  private dispatch;
15
20
  private safeExtractMeta;
21
+ /**
22
+ * status は発火元 gateway に束縛して受ける。交換済み (stale) な gateway からの通知は無視し、
23
+ * 現行 gateway の状態を誤って上書きしない
24
+ */
16
25
  private handleGatewayStatus;
26
+ /**
27
+ * resume 可能な session はこの時点で捕捉して次の gateway へ引き継ぐ (gateway インスタンスは
28
+ * 接続ごとに破棄されるため)。resume できない = IDENTIFY し直す再接続には identify rate limit
29
+ * (1 回 / 5 秒) を守る下限 delay を敷く
30
+ */
17
31
  private scheduleReconnect;
18
32
  }
19
33
  //#endregion
package/dist/discord.js CHANGED
@@ -1,7 +1,7 @@
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.js";
1
+ import { a as safeNow, c as attempt, i as FlumeLogger, l as safeNormalizeError, o as FlumeStartError, r as safeInvokeCallback, s as FlumeParseError, t as FlumeSource, u as safeErrorMessage } from "./flume-source.js";
2
2
  import { t as FlumeConnectionError } from "./connection-error.js";
3
- import { t as isRecord } from "./is-record.js";
4
3
  import { i as safeRandom, n as scheduleFlumeReconnect, r as FlumeReconnector, t as safeStringify } from "./safe-stringify.js";
4
+ import { t as isRecord } from "./is-record.js";
5
5
  import { t as safeJsonParse } from "./safe-json-parse.js";
6
6
  import { z } from "zod/v4";
7
7
  //#region lib/discord/discord-gateway-session.ts
@@ -56,17 +56,23 @@ var FlumeDiscordHeartbeat = class {
56
56
  initialTimer = null;
57
57
  intervalTimer = null;
58
58
  ackReceived = true;
59
+ generation = 0;
59
60
  constructor(props) {
60
61
  this.props = props;
61
62
  }
62
63
  start(intervalMs) {
63
64
  this.stop();
64
65
  this.ackReceived = true;
66
+ const scheduledGeneration = ++this.generation;
65
67
  const initialDelay = safeRandom({ deps: this.props.deps }) * intervalMs;
66
68
  const initialResult = attempt(() => this.props.deps.setTimeout(() => {
69
+ if (scheduledGeneration !== this.generation) return;
67
70
  this.initialTimer = null;
68
71
  this.safeFire();
69
- const intervalResult = attempt(() => this.props.deps.setInterval(() => this.safeFire(), intervalMs));
72
+ if (scheduledGeneration !== this.generation) return;
73
+ const intervalResult = attempt(() => this.props.deps.setInterval(() => {
74
+ if (scheduledGeneration === this.generation) this.safeFire();
75
+ }, intervalMs));
70
76
  if (intervalResult instanceof Error) {
71
77
  this.props.log.error({
72
78
  action: "heartbeat.interval.schedule.error",
@@ -74,6 +80,7 @@ var FlumeDiscordHeartbeat = class {
74
80
  error: intervalResult
75
81
  });
76
82
  this.intervalTimer = null;
83
+ this.safeZombie();
77
84
  } else this.intervalTimer = intervalResult;
78
85
  }, initialDelay));
79
86
  if (initialResult instanceof Error) {
@@ -83,9 +90,12 @@ var FlumeDiscordHeartbeat = class {
83
90
  error: initialResult
84
91
  });
85
92
  this.initialTimer = null;
93
+ return initialResult;
86
94
  } else this.initialTimer = initialResult;
95
+ return null;
87
96
  }
88
97
  stop() {
98
+ this.generation++;
89
99
  if (this.initialTimer !== null) {
90
100
  const handle = this.initialTimer;
91
101
  const r = attempt(() => this.props.deps.clearTimeout(handle));
@@ -110,6 +120,9 @@ var FlumeDiscordHeartbeat = class {
110
120
  ack() {
111
121
  this.ackReceived = true;
112
122
  }
123
+ request() {
124
+ this.safeFire();
125
+ }
113
126
  isRunning() {
114
127
  return this.initialTimer !== null || this.intervalTimer !== null;
115
128
  }
@@ -125,6 +138,18 @@ var FlumeDiscordHeartbeat = class {
125
138
  }
126
139
  });
127
140
  }
141
+ safeZombie() {
142
+ safeInvokeCallback({
143
+ fn: this.props.onZombie,
144
+ onError: (error) => {
145
+ this.props.log.error({
146
+ action: "heartbeat.zombie.error",
147
+ message: safeErrorMessage({ error }),
148
+ error
149
+ });
150
+ }
151
+ });
152
+ }
128
153
  fire() {
129
154
  if (!this.ackReceived) {
130
155
  this.props.onZombie();
@@ -138,22 +163,35 @@ var FlumeDiscordHeartbeat = class {
138
163
  //#region lib/discord/discord-gateway-message-schema.ts
139
164
  const FlumeGatewayMessageSchema = z.object({
140
165
  op: z.number(),
141
- d: z.unknown(),
142
- s: z.number().nullable(),
143
- t: z.string().nullable()
166
+ d: z.unknown().optional(),
167
+ s: z.number().nullish(),
168
+ t: z.string().nullish()
144
169
  });
145
170
  //#endregion
146
171
  //#region lib/discord/parse-discord-gateway-message.ts
172
+ /**
173
+ * 省略された d / s / t は null に正規化して返す (schema は nullish を許すが、
174
+ * 消費側が undefined を意識しなくて済むようここで揃える)
175
+ */
147
176
  function parseFlumeDiscordGatewayMessage(raw) {
148
177
  const json = safeJsonParse(raw);
149
178
  if (json instanceof FlumeParseError) return json;
150
179
  const parsed = FlumeGatewayMessageSchema.safeParse(json);
151
180
  if (!parsed.success) return new FlumeParseError(`invalid gateway message frame (${raw.length} bytes)`, { cause: parsed.error });
152
- return parsed.data;
181
+ return {
182
+ op: parsed.data.op,
183
+ d: parsed.data.d ?? null,
184
+ s: parsed.data.s ?? null,
185
+ t: parsed.data.t ?? null
186
+ };
153
187
  }
154
188
  //#endregion
155
189
  //#region lib/discord/discord-gateway.ts
156
190
  const GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
191
+ const GATEWAY_VERSION = "10";
192
+ const GATEWAY_ENCODING = "json";
193
+ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 3e4;
194
+ const FORCED_CLOSE_FALLBACK_MS = 5e3;
157
195
  const OP_DISPATCH = 0;
158
196
  const OP_HEARTBEAT = 1;
159
197
  const OP_IDENTIFY = 2;
@@ -181,10 +219,13 @@ const TERMINAL_CLOSE_CODES = new Set([
181
219
  4013,
182
220
  4014
183
221
  ]);
222
+ const NON_RESUMABLE_CLOSE_CODES = new Set([4007, 4009]);
184
223
  /**
185
224
  * Discord Gateway v10 の最小実装。HELLO -> IDENTIFY/RESUME -> READY/RESUMED -> dispatch を扱う。
186
225
  * READY 後の WebSocket 切断のみ `onStatus("disconnected")` を発火し source 側で再接続する。
187
226
  * 終端 close code (4004 / 401x) を受けた場合は stopped 化して再接続を抑止。
227
+ * close code 4007/4009 と INVALID_SESSION (resumable=false) では session を破棄する
228
+ * (source が次の gateway へ session を引き継ぐ前にここでリセットしておく)。
188
229
  * IO 境界は全て `attempt` 経由で扱い、コンストラクタ throw も `FlumeConnectionError` として返す
189
230
  * (`connect()` は決して reject しない)
190
231
  */
@@ -193,14 +234,18 @@ var FlumeDiscordGateway = class {
193
234
  log;
194
235
  ws = null;
195
236
  heartbeat = null;
196
- currentSession = FlumeDiscordGatewaySession.empty();
237
+ currentSession;
197
238
  isStoppedFlag = false;
198
239
  hasConnected = false;
199
240
  pendingResolve = null;
200
241
  pendingResolved = false;
201
242
  invalidSessionTimer = null;
243
+ handshakeTimer = null;
244
+ forcedCloseFallbackTimer = null;
245
+ teardownDone = false;
202
246
  constructor(props) {
203
247
  this.props = props;
248
+ this.currentSession = props.session ?? FlumeDiscordGatewaySession.empty();
204
249
  this.log = new FlumeLogger({
205
250
  source: "discord.gateway",
206
251
  handler: props.onLog,
@@ -224,7 +269,7 @@ var FlumeDiscordGateway = class {
224
269
  });
225
270
  return Promise.resolve(error);
226
271
  }
227
- const target = url ?? GATEWAY_URL;
272
+ const target = this.resolveTargetUrl(url);
228
273
  const hostResult = attempt(() => new URL(target).hostname);
229
274
  const host = hostResult instanceof Error ? "unknown" : hostResult;
230
275
  this.log.info({
@@ -233,6 +278,7 @@ var FlumeDiscordGateway = class {
233
278
  });
234
279
  this.pendingResolved = false;
235
280
  this.hasConnected = false;
281
+ this.teardownDone = false;
236
282
  return new Promise((resolve) => {
237
283
  this.pendingResolve = resolve;
238
284
  const socketResult = attempt(() => new WS(target));
@@ -262,10 +308,13 @@ var FlumeDiscordGateway = class {
262
308
  message: safeErrorMessage({ error }),
263
309
  error
264
310
  });
311
+ this.closeSocket({ ws: socket });
265
312
  this.ws = null;
266
313
  this.pendingResolved = true;
267
314
  resolve(error);
315
+ return;
268
316
  }
317
+ this.armHandshakeTimer();
269
318
  });
270
319
  }
271
320
  disconnect() {
@@ -276,6 +325,8 @@ var FlumeDiscordGateway = class {
276
325
  this.isStoppedFlag = true;
277
326
  this.heartbeat?.stop();
278
327
  this.clearInvalidSessionTimer();
328
+ this.clearForcedCloseFallbackTimer();
329
+ this.completeConnect(new FlumeConnectionError("gateway disconnected before ready"));
279
330
  this.closeSocket({
280
331
  ws: this.ws,
281
332
  code: 1e3,
@@ -286,7 +337,153 @@ var FlumeDiscordGateway = class {
286
337
  isConnected() {
287
338
  return this.ws !== null && this.ws.readyState === WS_OPEN;
288
339
  }
340
+ /**
341
+ * resume 時は Discord の `resume_gateway_url` に query (?v=10&encoding=json) を付与する
342
+ * (Discord が返す URL に query は付かない)。URL が壊れている場合は session を破棄して
343
+ * 通常の Gateway URL で IDENTIFY し直す
344
+ */
345
+ resolveTargetUrl(url) {
346
+ if (url === void 0) return GATEWAY_URL;
347
+ const rebuilt = attempt(() => {
348
+ const parsed = new URL(url);
349
+ parsed.searchParams.set("v", GATEWAY_VERSION);
350
+ parsed.searchParams.set("encoding", GATEWAY_ENCODING);
351
+ return parsed.toString();
352
+ });
353
+ if (rebuilt instanceof Error) {
354
+ this.log.warn({
355
+ action: "resume.url.invalid",
356
+ message: `resume url unparseable, dropping session and identifying fresh: ${safeErrorMessage({ error: rebuilt })}`
357
+ });
358
+ this.currentSession = this.currentSession.withReset();
359
+ return GATEWAY_URL;
360
+ }
361
+ return rebuilt;
362
+ }
363
+ resolveHandshakeTimeoutMs() {
364
+ const configured = this.props.handshakeTimeoutMs;
365
+ if (typeof configured !== "number" || !Number.isFinite(configured) || configured <= 0) return DEFAULT_HANDSHAKE_TIMEOUT_MS;
366
+ return configured;
367
+ }
368
+ armHandshakeTimer() {
369
+ this.clearHandshakeTimer();
370
+ const timeoutMs = this.resolveHandshakeTimeoutMs();
371
+ const timerResult = attempt(() => this.props.deps.setTimeout(() => {
372
+ this.handshakeTimer = null;
373
+ this.onHandshakeTimeout(timeoutMs);
374
+ }, timeoutMs));
375
+ if (timerResult instanceof Error) {
376
+ const error = new FlumeConnectionError(`handshake timer scheduling failed: ${safeErrorMessage({ error: timerResult })}`, { cause: timerResult });
377
+ this.log.error({
378
+ action: "handshake.timer.schedule.error",
379
+ message: safeErrorMessage({ error }),
380
+ error
381
+ });
382
+ this.handshakeTimer = null;
383
+ this.completeConnect(error);
384
+ this.forceClose({
385
+ code: 4e3,
386
+ reason: "handshake timer failure"
387
+ });
388
+ return;
389
+ }
390
+ this.handshakeTimer = timerResult;
391
+ }
392
+ clearHandshakeTimer() {
393
+ if (this.handshakeTimer === null) return;
394
+ const handle = this.handshakeTimer;
395
+ const result = attempt(() => this.props.deps.clearTimeout(handle));
396
+ if (result instanceof Error) this.log.error({
397
+ action: "handshake.timer.clear.error",
398
+ message: safeErrorMessage({ error: result }),
399
+ error: result
400
+ });
401
+ this.handshakeTimer = null;
402
+ }
403
+ /**
404
+ * READY/RESUMED が期限内に来なかった half-open socket。connect() を Error で解放して
405
+ * source の再接続経路に乗せ、socket は強制 close する (close event が来なければ
406
+ * fallback が teardown を合成する)
407
+ */
408
+ onHandshakeTimeout(timeoutMs) {
409
+ if (this.pendingResolved) return;
410
+ const error = new FlumeConnectionError(`handshake timeout after ${timeoutMs}ms (no READY/RESUMED)`);
411
+ this.log.error({
412
+ action: "handshake.timeout",
413
+ message: safeErrorMessage({ error }),
414
+ error
415
+ });
416
+ this.completeConnect(error);
417
+ this.forceClose({
418
+ code: 4e3,
419
+ reason: "handshake timeout"
420
+ });
421
+ }
422
+ /**
423
+ * zombie / handshake timeout / malformed HELLO で自発的に接続を落とす。死んだ TCP 経路では
424
+ * close event が届かないことがあるため fallback timer で teardown を保証する。heartbeat は
425
+ * 即座に止めて onZombie が interval ごとに再発火するのを防ぐ。close code は resume 可能な
426
+ * 4000 を使う (4009 は session 無効化コードと衝突する)
427
+ */
428
+ forceClose(input) {
429
+ this.heartbeat?.stop();
430
+ this.closeSocket({
431
+ ws: input.ws ?? this.ws,
432
+ code: input.code,
433
+ reason: input.reason
434
+ });
435
+ this.armForcedCloseFallback();
436
+ }
437
+ armForcedCloseFallback() {
438
+ if (this.teardownDone) return;
439
+ if (this.forcedCloseFallbackTimer !== null) return;
440
+ const timerResult = attempt(() => this.props.deps.setTimeout(() => {
441
+ this.forcedCloseFallbackTimer = null;
442
+ this.synthesizeTeardown();
443
+ }, FORCED_CLOSE_FALLBACK_MS));
444
+ if (timerResult instanceof Error) {
445
+ this.log.error({
446
+ action: "ws.close.fallback.schedule.error",
447
+ message: safeErrorMessage({ error: timerResult }),
448
+ error: timerResult
449
+ });
450
+ this.forcedCloseFallbackTimer = null;
451
+ this.synthesizeTeardown();
452
+ return;
453
+ }
454
+ this.forcedCloseFallbackTimer = timerResult;
455
+ }
456
+ clearForcedCloseFallbackTimer() {
457
+ if (this.forcedCloseFallbackTimer === null) return;
458
+ const handle = this.forcedCloseFallbackTimer;
459
+ const result = attempt(() => this.props.deps.clearTimeout(handle));
460
+ if (result instanceof Error) this.log.error({
461
+ action: "ws.close.fallback.clear.error",
462
+ message: safeErrorMessage({ error: result }),
463
+ error: result
464
+ });
465
+ this.forcedCloseFallbackTimer = null;
466
+ }
467
+ /**
468
+ * 強制 close 後に close event が届かなかった場合の合成 teardown。`onClose` と排他で
469
+ * 一度だけ実行する。READY 後なら `onStatus("disconnected")` で source の再接続に繋ぐ
470
+ * (READY 前は connect() の Error 解決が既に再接続を駆動しているため通知しない)
471
+ */
472
+ synthesizeTeardown() {
473
+ if (this.teardownDone) return;
474
+ this.teardownDone = true;
475
+ this.log.warn({
476
+ action: "ws.close.synthesized",
477
+ message: "close event not received after forced close, synthesizing teardown"
478
+ });
479
+ this.ws = null;
480
+ this.heartbeat?.stop();
481
+ this.clearInvalidSessionTimer();
482
+ if (!this.pendingResolved) this.completeConnect(new FlumeConnectionError("WebSocket force-closed (no close event)"));
483
+ if (this.hasConnected && !this.isStoppedFlag) this.props.onStatus("disconnected");
484
+ }
289
485
  completeConnect(error) {
486
+ this.clearHandshakeTimer();
290
487
  if (this.pendingResolved || !this.pendingResolve) return;
291
488
  this.pendingResolved = true;
292
489
  this.pendingResolve(error);
@@ -337,7 +534,7 @@ var FlumeDiscordGateway = class {
337
534
  length: raw.length
338
535
  }
339
536
  });
340
- if (parsed.s !== null) this.currentSession = this.currentSession.withSeq(parsed.s);
537
+ if (typeof parsed.s === "number") this.currentSession = this.currentSession.withSeq(parsed.s);
341
538
  if (parsed.op === OP_HELLO) return this.onHello(parsed);
342
539
  if (parsed.op === OP_HEARTBEAT_ACK) return this.onHeartbeatAck();
343
540
  if (parsed.op === OP_HEARTBEAT) return this.onHeartbeatRequest();
@@ -352,7 +549,23 @@ var FlumeDiscordGateway = class {
352
549
  }
353
550
  onHello(msg) {
354
551
  const d = isRecord(msg.d) ? msg.d : null;
355
- const interval = d && typeof d.heartbeat_interval === "number" ? d.heartbeat_interval : 0;
552
+ const rawInterval = d === null ? null : d.heartbeat_interval;
553
+ if (typeof rawInterval !== "number" || !Number.isFinite(rawInterval) || rawInterval <= 0) {
554
+ const error = new FlumeConnectionError("malformed HELLO: heartbeat_interval is not a finite number > 0");
555
+ this.log.error({
556
+ action: "gateway.hello.invalid",
557
+ message: safeErrorMessage({ error }),
558
+ error,
559
+ detail: { intervalType: typeof rawInterval }
560
+ });
561
+ this.completeConnect(error);
562
+ this.forceClose({
563
+ code: 4e3,
564
+ reason: "malformed HELLO"
565
+ });
566
+ return;
567
+ }
568
+ const interval = rawInterval;
356
569
  this.log.info({
357
570
  action: "gateway.hello",
358
571
  message: `heartbeat_interval=${interval}ms`,
@@ -377,14 +590,22 @@ var FlumeDiscordGateway = class {
377
590
  action: "heartbeat.zombie",
378
591
  message: "no ACK received, closing connection"
379
592
  });
380
- this.closeSocket({
381
- ws: this.ws,
382
- code: 4009,
593
+ this.forceClose({
594
+ code: 4e3,
383
595
  reason: "zombie connection"
384
596
  });
385
597
  }
386
598
  });
387
- this.heartbeat.start(interval);
599
+ const heartbeatError = this.heartbeat.start(interval);
600
+ if (heartbeatError instanceof Error) {
601
+ const error = new FlumeConnectionError(`heartbeat timer scheduling failed: ${safeErrorMessage({ error: heartbeatError })}`, { cause: heartbeatError });
602
+ this.completeConnect(error);
603
+ this.forceClose({
604
+ code: 4e3,
605
+ reason: "heartbeat timer failure"
606
+ });
607
+ return;
608
+ }
388
609
  if (this.currentSession.canResume()) this.sendResume();
389
610
  else this.sendIdentify();
390
611
  }
@@ -400,17 +621,14 @@ var FlumeDiscordGateway = class {
400
621
  action: "heartbeat.requested",
401
622
  message: "server requested heartbeat"
402
623
  });
403
- this.send({
404
- op: OP_HEARTBEAT,
405
- d: this.currentSession.seq
406
- });
624
+ this.heartbeat?.request();
407
625
  }
408
626
  onReconnectRequest(socket) {
409
627
  this.log.info({
410
628
  action: "ws.reconnect.requested",
411
629
  message: "server requested reconnect"
412
630
  });
413
- this.closeSocket({
631
+ this.forceClose({
414
632
  ws: socket,
415
633
  code: 4e3,
416
634
  reason: "reconnect requested"
@@ -428,7 +646,7 @@ var FlumeDiscordGateway = class {
428
646
  this.clearInvalidSessionTimer();
429
647
  const timerResult = attempt(() => this.props.deps.setTimeout(() => {
430
648
  this.invalidSessionTimer = null;
431
- this.closeSocket({
649
+ this.forceClose({
432
650
  ws: socket,
433
651
  code: 4e3,
434
652
  reason: "invalid session"
@@ -441,6 +659,11 @@ var FlumeDiscordGateway = class {
441
659
  error: timerResult
442
660
  });
443
661
  this.invalidSessionTimer = null;
662
+ this.forceClose({
663
+ ws: socket,
664
+ code: 4e3,
665
+ reason: "invalid session timer failure"
666
+ });
444
667
  } else this.invalidSessionTimer = timerResult;
445
668
  }
446
669
  onDispatch(msg) {
@@ -475,6 +698,16 @@ var FlumeDiscordGateway = class {
475
698
  });
476
699
  }
477
700
  onClose(ev) {
701
+ this.clearForcedCloseFallbackTimer();
702
+ if (this.teardownDone) {
703
+ this.log.debug({
704
+ action: "ws.close.stale",
705
+ message: `ignored close after synthesized teardown (code=${ev.code})`,
706
+ detail: { code: ev.code }
707
+ });
708
+ return;
709
+ }
710
+ this.teardownDone = true;
478
711
  const terminal = TERMINAL_CLOSE_CODES.has(ev.code);
479
712
  this.log.info({
480
713
  action: "ws.close",
@@ -488,6 +721,14 @@ var FlumeDiscordGateway = class {
488
721
  this.ws = null;
489
722
  this.heartbeat?.stop();
490
723
  this.clearInvalidSessionTimer();
724
+ if (NON_RESUMABLE_CLOSE_CODES.has(ev.code)) {
725
+ this.log.info({
726
+ action: "session.reset",
727
+ message: `close code ${ev.code} invalidates session, next attempt will identify`,
728
+ detail: { code: ev.code }
729
+ });
730
+ this.currentSession = this.currentSession.withReset();
731
+ }
491
732
  if (terminal) this.isStoppedFlag = true;
492
733
  if (this.hasConnected || terminal) this.props.onStatus("disconnected");
493
734
  if (!this.pendingResolved) {
@@ -642,12 +883,14 @@ function flumeExtractDiscordMeta(eventName, eventData) {
642
883
  const meta = { event_type: eventName };
643
884
  if (typeof eventData.channel_id === "string") meta.channel_id = eventData.channel_id;
644
885
  if (typeof eventData.guild_id === "string") meta.guild_id = eventData.guild_id;
645
- if (isRecord(eventData.author) && typeof eventData.author.id === "string") meta.user_id = eventData.author.id;
886
+ if (typeof eventData.user_id === "string") meta.user_id = eventData.user_id;
887
+ else if (isRecord(eventData.author) && typeof eventData.author.id === "string") meta.user_id = eventData.author.id;
646
888
  return meta;
647
889
  }
648
890
  //#endregion
649
891
  //#region lib/discord/discord-source.ts
650
892
  const DEFAULT_INTENTS = FlumeDiscordGatewayIntents.Guilds | FlumeDiscordGatewayIntents.GuildMessages | FlumeDiscordGatewayIntents.DirectMessages;
893
+ const IDENTIFY_MIN_RECONNECT_DELAY_MS = 5e3;
651
894
  var FlumeDiscordSource = class extends FlumeSource {
652
895
  options;
653
896
  name = "discord";
@@ -688,28 +931,37 @@ var FlumeDiscordSource = class extends FlumeSource {
688
931
  }
689
932
  return result;
690
933
  }
691
- async connectInternal(ctx, resumeUrl) {
934
+ /**
935
+ * gateway を 1 接続 = 1 インスタンスで作り直す。`session` は前回接続から引き継いだ
936
+ * resume 可能な session (無ければ IDENTIFY)。await 後は `this.gateway` でなく local な
937
+ * `gateway` を参照する (並行する close() が `this.gateway` を null 化しても壊れない)
938
+ */
939
+ async connectInternal(ctx, session) {
692
940
  this.setStatus("connecting");
693
- this.gateway = new FlumeDiscordGateway({
941
+ const gateway = new FlumeDiscordGateway({
694
942
  token: this.options.token,
695
943
  intents: this.options.intents ?? DEFAULT_INTENTS,
944
+ handshakeTimeoutMs: this.options.handshakeTimeoutMs,
945
+ session,
696
946
  onLog: ctx.log.handler,
697
947
  deps: ctx.deps,
698
948
  onDispatch: (eventName, eventData) => this.dispatch(ctx, eventName, eventData),
699
- onStatus: (status) => this.handleGatewayStatus(ctx, status)
949
+ onStatus: (status) => this.handleGatewayStatus(ctx, gateway, status)
700
950
  });
701
- const error = await this.gateway.connect(resumeUrl);
951
+ this.gateway = gateway;
952
+ const resumeUrl = session !== void 0 && session.canResume() && session.resumeUrl !== null ? session.resumeUrl : void 0;
953
+ const error = await gateway.connect(resumeUrl);
702
954
  if (error instanceof FlumeConnectionError) {
703
955
  ctx.log.error({
704
956
  action: "connect.failed",
705
957
  message: safeErrorMessage({ error }),
706
958
  error
707
959
  });
708
- if (this.gateway.isStopped || !this.reconnector || this.reconnector.aborted) {
960
+ if (gateway.isStopped || !this.reconnector || this.reconnector.aborted) {
709
961
  this.setStatus("disconnected");
710
962
  return error;
711
963
  }
712
- this.scheduleReconnect(ctx);
964
+ this.scheduleReconnect(ctx, gateway);
713
965
  }
714
966
  return null;
715
967
  }
@@ -736,7 +988,18 @@ var FlumeDiscordSource = class extends FlumeSource {
736
988
  }
737
989
  return result;
738
990
  }
739
- handleGatewayStatus(ctx, status) {
991
+ /**
992
+ * status は発火元 gateway に束縛して受ける。交換済み (stale) な gateway からの通知は無視し、
993
+ * 現行 gateway の状態を誤って上書きしない
994
+ */
995
+ handleGatewayStatus(ctx, gateway, status) {
996
+ if (gateway !== this.gateway) {
997
+ ctx.log.debug({
998
+ action: "gateway.status.stale",
999
+ message: `ignored ${status} from replaced gateway`
1000
+ });
1001
+ return;
1002
+ }
740
1003
  if (status === "connected") {
741
1004
  if (this.reconnector && this.reconnector.attempt > 0) ctx.log.info({
742
1005
  action: "reconnect.reset",
@@ -746,20 +1009,26 @@ var FlumeDiscordSource = class extends FlumeSource {
746
1009
  this.setStatus("connected");
747
1010
  return;
748
1011
  }
749
- if (this.gateway?.isStopped) {
1012
+ if (gateway.isStopped) {
750
1013
  this.setStatus("disconnected");
751
1014
  return;
752
1015
  }
753
- this.scheduleReconnect(ctx);
1016
+ this.scheduleReconnect(ctx, gateway);
754
1017
  }
755
- scheduleReconnect(ctx) {
756
- const url = this.gateway?.session.resumeUrl ?? void 0;
1018
+ /**
1019
+ * resume 可能な session はこの時点で捕捉して次の gateway へ引き継ぐ (gateway インスタンスは
1020
+ * 接続ごとに破棄されるため)。resume できない = IDENTIFY し直す再接続には identify rate limit
1021
+ * (1 回 / 5 秒) を守る下限 delay を敷く
1022
+ */
1023
+ scheduleReconnect(ctx, gateway) {
1024
+ const capturedSession = gateway.session.canResume() ? gateway.session : void 0;
757
1025
  scheduleFlumeReconnect({
758
1026
  reconnector: this.reconnector,
759
1027
  log: ctx.log,
760
1028
  setStatus: (status) => this.setStatus(status),
1029
+ minDelayMs: capturedSession === void 0 ? IDENTIFY_MIN_RECONNECT_DELAY_MS : void 0,
761
1030
  retry: () => {
762
- this.connectInternal(ctx, url).catch((err) => {
1031
+ this.connectInternal(ctx, capturedSession).catch((err) => {
763
1032
  const error = safeNormalizeError({ value: err });
764
1033
  ctx.log.error({
765
1034
  action: "reconnect.unhandled",