@interactive-inc/flume 0.9.4 → 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 +3 -2
- package/dist/discord.d.ts +15 -1
- package/dist/discord.js +302 -33
- package/dist/flume-source.d.ts +95 -12
- package/dist/flume-source.js +141 -68
- package/dist/github.d.ts +5 -1
- package/dist/github.js +211 -61
- package/dist/http-error.js +4 -0
- package/dist/index.d.ts +138 -23
- package/dist/index.js +358 -72
- package/dist/safe-json-parse.js +1 -1
- package/dist/safe-read-text.js +6 -3
- package/dist/safe-stringify.js +64 -28
- package/dist/slack.d.ts +8 -1
- package/dist/slack.js +227 -30
- package/dist/time.d.ts +46 -4
- package/dist/time.js +342 -40
- package/package.json +1 -1
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:
|
|
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 {
|
|
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,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
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
3
|
import { i as safeRandom, n as scheduleFlumeReconnect, r as FlumeReconnector, t as safeStringify } from "./safe-stringify.js";
|
|
4
4
|
import { t as isRecord } from "./is-record.js";
|
|
@@ -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
|
-
|
|
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().
|
|
143
|
-
t: z.string().
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
381
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
756
|
-
|
|
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,
|
|
1031
|
+
this.connectInternal(ctx, capturedSession).catch((err) => {
|
|
763
1032
|
const error = safeNormalizeError({ value: err });
|
|
764
1033
|
ctx.log.error({
|
|
765
1034
|
action: "reconnect.unhandled",
|