@interactive-inc/flume 0.10.0 → 0.11.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/README.md +93 -32
- package/dist/discord.d.ts +15 -1
- package/dist/discord.js +304 -35
- package/dist/flume-source.d.ts +58 -14
- package/dist/flume-source.js +142 -87
- package/dist/github.d.ts +5 -1
- package/dist/github.js +217 -69
- package/dist/index.d.ts +223 -46
- package/dist/index.js +466 -149
- package/dist/parse-error.d.ts +9 -0
- package/dist/safe-json-parse.js +1 -1
- package/dist/safe-read-text.js +22 -5
- package/dist/safe-stringify.js +34 -134
- package/dist/schedule-reconnect.js +153 -0
- package/dist/slack.d.ts +8 -1
- package/dist/slack.js +230 -34
- package/dist/time.d.ts +81 -2
- package/dist/time.js +625 -2
- package/package.json +11 -4
- package/dist/connection-error.js +0 -16
- package/dist/http-error.js +0 -12
- package/dist/parse-cron.d.ts +0 -55
- package/dist/time-source.js +0 -427
package/dist/slack.js
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { c as FlumeParseError, d as safeErrorMessage, i as safeNow, l as attempt, r as FlumeLogger, s as FlumeStartError, t as FlumeSource, u as safeNormalizeError } from "./flume-source.js";
|
|
2
|
+
import { r as FlumeConnectionError, t as safeStringify } from "./safe-stringify.js";
|
|
3
|
+
import { n as FlumeHttpError, t as safeReadText } from "./safe-read-text.js";
|
|
4
4
|
import { t as isRecord } from "./is-record.js";
|
|
5
|
-
import { n as scheduleFlumeReconnect, r as FlumeReconnector, t as safeStringify } from "./safe-stringify.js";
|
|
6
5
|
import { t as safeJsonParse } from "./safe-json-parse.js";
|
|
7
|
-
import { t as
|
|
6
|
+
import { n as FlumeReconnector, t as scheduleFlumeReconnect } from "./schedule-reconnect.js";
|
|
8
7
|
import { z } from "zod/v4";
|
|
9
8
|
//#region lib/slack/extract-slack-meta.ts
|
|
10
9
|
function flumeExtractSlackMeta(envelope) {
|
|
11
10
|
const meta = { event_type: envelope.type };
|
|
12
11
|
const eventPayload = isRecord(envelope.payload.event) ? envelope.payload.event : null;
|
|
13
|
-
if (
|
|
14
|
-
if (typeof eventPayload.channel === "string") meta.channel_id = eventPayload.channel;
|
|
15
|
-
if (typeof
|
|
16
|
-
if (typeof eventPayload.
|
|
17
|
-
if (typeof eventPayload.
|
|
12
|
+
if (typeof envelope.payload.channel_id === "string") meta.channel_id = envelope.payload.channel_id;
|
|
13
|
+
else if (eventPayload && typeof eventPayload.channel === "string") meta.channel_id = eventPayload.channel;
|
|
14
|
+
if (typeof envelope.payload.user_id === "string") meta.user_id = envelope.payload.user_id;
|
|
15
|
+
else if (eventPayload && typeof eventPayload.user === "string") meta.user_id = eventPayload.user;
|
|
16
|
+
if (eventPayload && typeof eventPayload.thread_ts === "string") meta.thread_ts = eventPayload.thread_ts;
|
|
17
|
+
if (eventPayload && typeof eventPayload.type === "string") meta.slack_event_type = eventPayload.type;
|
|
18
18
|
return meta;
|
|
19
19
|
}
|
|
20
20
|
//#endregion
|
|
@@ -65,6 +65,22 @@ const FlumeSlackConnectionResponseSchema = z.object({
|
|
|
65
65
|
error: z.string().optional()
|
|
66
66
|
});
|
|
67
67
|
//#endregion
|
|
68
|
+
//#region lib/slack/read-slack-retry-after-ms.ts
|
|
69
|
+
/**
|
|
70
|
+
* `Retry-After` ヘッダを ms で読む。整数秒表記のみ受理し、HTTP-date 形式・非数値・
|
|
71
|
+
* headers 欠落 (モック Response 等) はすべて null。throw しない
|
|
72
|
+
*/
|
|
73
|
+
function readSlackRetryAfterMs(props) {
|
|
74
|
+
const raw = attempt(() => props.response.headers.get("retry-after"));
|
|
75
|
+
if (raw instanceof Error) return null;
|
|
76
|
+
if (typeof raw !== "string") return null;
|
|
77
|
+
const trimmed = raw.trim();
|
|
78
|
+
if (!/^\d+$/.test(trimmed)) return null;
|
|
79
|
+
const seconds = Number(trimmed);
|
|
80
|
+
if (!Number.isFinite(seconds)) return null;
|
|
81
|
+
return seconds * 1e3;
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
68
84
|
//#region lib/slack/obtain-slack-url.ts
|
|
69
85
|
const URL_ENDPOINT = "https://slack.com/api/apps.connections.open";
|
|
70
86
|
async function obtainSlackUrl(props) {
|
|
@@ -99,6 +115,19 @@ async function obtainSlackUrl(props) {
|
|
|
99
115
|
url: URL_ENDPOINT
|
|
100
116
|
}
|
|
101
117
|
});
|
|
118
|
+
const retryAfterMs = readSlackRetryAfterMs({ response });
|
|
119
|
+
if (response.status === 429) {
|
|
120
|
+
log.warn({
|
|
121
|
+
action: "http.rate-limited",
|
|
122
|
+
message: `apps.connections.open: rate limited (429), retry-after ${retryAfterMs ?? "unknown"}ms`,
|
|
123
|
+
detail: { retryAfterMs }
|
|
124
|
+
});
|
|
125
|
+
return new FlumeHttpError({
|
|
126
|
+
message: "apps.connections.open: rate limited (429)",
|
|
127
|
+
status: response.status,
|
|
128
|
+
retryAfterMs
|
|
129
|
+
});
|
|
130
|
+
}
|
|
102
131
|
const text = await safeReadText({
|
|
103
132
|
response,
|
|
104
133
|
context: "apps.connections.open"
|
|
@@ -121,7 +150,8 @@ async function obtainSlackUrl(props) {
|
|
|
121
150
|
return new FlumeHttpError({
|
|
122
151
|
message: `apps.connections.open: invalid JSON body`,
|
|
123
152
|
status: response.status,
|
|
124
|
-
cause: json
|
|
153
|
+
cause: json,
|
|
154
|
+
retryAfterMs
|
|
125
155
|
});
|
|
126
156
|
}
|
|
127
157
|
const peek = isRecord(json) ? json : {};
|
|
@@ -146,17 +176,21 @@ async function obtainSlackUrl(props) {
|
|
|
146
176
|
return new FlumeHttpError({
|
|
147
177
|
message: "apps.connections.open: invalid response shape",
|
|
148
178
|
status: response.status,
|
|
149
|
-
cause: parsed.error
|
|
179
|
+
cause: parsed.error,
|
|
180
|
+
retryAfterMs
|
|
150
181
|
});
|
|
151
182
|
}
|
|
152
183
|
if (!parsed.data.ok || !parsed.data.url) {
|
|
153
184
|
log.warn({
|
|
154
185
|
action: "slack.api.error",
|
|
155
|
-
message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}
|
|
186
|
+
message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}`,
|
|
187
|
+
detail: { code: parsed.data.error ?? null }
|
|
156
188
|
});
|
|
157
189
|
return new FlumeHttpError({
|
|
158
190
|
message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}`,
|
|
159
|
-
status: response.status
|
|
191
|
+
status: response.status,
|
|
192
|
+
code: parsed.data.error ?? null,
|
|
193
|
+
retryAfterMs
|
|
160
194
|
});
|
|
161
195
|
}
|
|
162
196
|
log.info({
|
|
@@ -178,6 +212,12 @@ const FlumeSlackEnvelopeSchema = z.object({
|
|
|
178
212
|
//#endregion
|
|
179
213
|
//#region lib/slack/slack-socket-mode.ts
|
|
180
214
|
const IDLE_CHECK_INTERVAL_MS = 15e3;
|
|
215
|
+
const HANDSHAKE_TIMEOUT_DEFAULT_MS = 3e4;
|
|
216
|
+
/**
|
|
217
|
+
* 強制 close 後に close イベントの配達を待つ猶予。silently-dropped な NAT / proxy 経路では
|
|
218
|
+
* close が永遠に来ないことがあり、その場合 teardown を合成して reconnect へ渡す
|
|
219
|
+
*/
|
|
220
|
+
const CLOSE_FALLBACK_MS = 5e3;
|
|
181
221
|
const WS_OPEN = 1;
|
|
182
222
|
/**
|
|
183
223
|
* Slack Socket Mode の最小 WebSocket 実装。`apps.connections.open` で URL を取得し
|
|
@@ -194,6 +234,9 @@ var FlumeSlackSocketMode = class {
|
|
|
194
234
|
pendingResolved = false;
|
|
195
235
|
lastFrameAt = 0;
|
|
196
236
|
idleWatchdog = null;
|
|
237
|
+
handshakeTimer = null;
|
|
238
|
+
closeFallbackTimer = null;
|
|
239
|
+
closeHandled = false;
|
|
197
240
|
constructor(props) {
|
|
198
241
|
this.props = props;
|
|
199
242
|
this.log = new FlumeLogger({
|
|
@@ -253,13 +296,15 @@ var FlumeSlackSocketMode = class {
|
|
|
253
296
|
});
|
|
254
297
|
this.isStoppedFlag = true;
|
|
255
298
|
this.disarmIdleWatchdog();
|
|
299
|
+
this.clearCloseFallback();
|
|
300
|
+
this.completeConnect(new FlumeConnectionError("stopped before hello"));
|
|
256
301
|
this.closeSocket(this.ws);
|
|
257
302
|
this.ws = null;
|
|
258
303
|
}
|
|
259
304
|
armIdleWatchdog() {
|
|
260
305
|
this.disarmIdleWatchdog();
|
|
261
306
|
const idleLimit = this.props.idleTimeoutMs;
|
|
262
|
-
if (idleLimit
|
|
307
|
+
if (typeof idleLimit !== "number" || !Number.isFinite(idleLimit) || idleLimit <= 0) return;
|
|
263
308
|
const handle = attempt(() => this.props.deps.setInterval(() => this.checkIdle(idleLimit), IDLE_CHECK_INTERVAL_MS));
|
|
264
309
|
if (handle instanceof Error) {
|
|
265
310
|
this.log.error({
|
|
@@ -284,7 +329,7 @@ var FlumeSlackSocketMode = class {
|
|
|
284
329
|
}
|
|
285
330
|
checkIdle(idleLimit) {
|
|
286
331
|
if (!this.hasConnected || this.isStoppedFlag) return;
|
|
287
|
-
const elapsed = this.props.deps
|
|
332
|
+
const elapsed = safeNow({ deps: this.props.deps }) - this.lastFrameAt;
|
|
288
333
|
if (elapsed < idleLimit) return;
|
|
289
334
|
this.log.warn({
|
|
290
335
|
action: "idle.timeout",
|
|
@@ -295,7 +340,7 @@ var FlumeSlackSocketMode = class {
|
|
|
295
340
|
}
|
|
296
341
|
});
|
|
297
342
|
this.disarmIdleWatchdog();
|
|
298
|
-
this.
|
|
343
|
+
this.forceClose();
|
|
299
344
|
}
|
|
300
345
|
isConnected() {
|
|
301
346
|
return this.ws !== null && this.ws.readyState === WS_OPEN;
|
|
@@ -313,6 +358,7 @@ var FlumeSlackSocketMode = class {
|
|
|
313
358
|
}
|
|
314
359
|
this.pendingResolved = false;
|
|
315
360
|
this.hasConnected = false;
|
|
361
|
+
this.closeHandled = false;
|
|
316
362
|
return new Promise((resolve) => {
|
|
317
363
|
this.pendingResolve = resolve;
|
|
318
364
|
const socketResult = attempt(() => new WS(url));
|
|
@@ -342,17 +388,121 @@ var FlumeSlackSocketMode = class {
|
|
|
342
388
|
message: safeErrorMessage({ error }),
|
|
343
389
|
error
|
|
344
390
|
});
|
|
391
|
+
this.closeSocket(socket);
|
|
345
392
|
this.ws = null;
|
|
346
393
|
this.pendingResolved = true;
|
|
347
394
|
resolve(error);
|
|
395
|
+
return;
|
|
348
396
|
}
|
|
397
|
+
this.armHandshakeTimer();
|
|
349
398
|
});
|
|
350
399
|
}
|
|
351
400
|
completeConnect(error) {
|
|
401
|
+
this.clearHandshakeTimer();
|
|
352
402
|
if (this.pendingResolved || !this.pendingResolve) return;
|
|
353
403
|
this.pendingResolved = true;
|
|
354
404
|
this.pendingResolve(error);
|
|
355
405
|
}
|
|
406
|
+
armHandshakeTimer() {
|
|
407
|
+
const configured = this.props.handshakeTimeoutMs;
|
|
408
|
+
const timeoutMs = typeof configured === "number" && Number.isFinite(configured) && configured > 0 ? configured : HANDSHAKE_TIMEOUT_DEFAULT_MS;
|
|
409
|
+
const handle = attempt(() => this.props.deps.setTimeout(() => this.onHandshakeTimeout(timeoutMs), timeoutMs));
|
|
410
|
+
if (handle instanceof Error) {
|
|
411
|
+
const error = new FlumeConnectionError(`handshake timer scheduling failed: ${safeErrorMessage({ error: handle })}`, { cause: handle });
|
|
412
|
+
this.log.error({
|
|
413
|
+
action: "handshake.timer.schedule.error",
|
|
414
|
+
message: safeErrorMessage({ error }),
|
|
415
|
+
error
|
|
416
|
+
});
|
|
417
|
+
this.completeConnect(error);
|
|
418
|
+
this.forceClose();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
this.handshakeTimer = handle;
|
|
422
|
+
}
|
|
423
|
+
clearHandshakeTimer() {
|
|
424
|
+
if (this.handshakeTimer === null) return;
|
|
425
|
+
const handle = this.handshakeTimer;
|
|
426
|
+
this.handshakeTimer = null;
|
|
427
|
+
const result = attempt(() => this.props.deps.clearTimeout(handle));
|
|
428
|
+
if (result instanceof Error) this.log.error({
|
|
429
|
+
action: "handshake.timer.clear.error",
|
|
430
|
+
message: safeErrorMessage({ error: result }),
|
|
431
|
+
error: result
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
onHandshakeTimeout(timeoutMs) {
|
|
435
|
+
this.handshakeTimer = null;
|
|
436
|
+
if (this.pendingResolved || this.isStoppedFlag) return;
|
|
437
|
+
const error = new FlumeConnectionError(`handshake timeout: no hello within ${timeoutMs}ms — force-closing socket`);
|
|
438
|
+
this.log.error({
|
|
439
|
+
action: "handshake.timeout",
|
|
440
|
+
message: safeErrorMessage({ error }),
|
|
441
|
+
error,
|
|
442
|
+
detail: { timeoutMs }
|
|
443
|
+
});
|
|
444
|
+
this.completeConnect(error);
|
|
445
|
+
this.forceClose();
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* watchdog / handshake timeout / server disconnect 指示による強制 close の入口。
|
|
449
|
+
* close イベントが配達されない dead pipe に備え、close() より先に fallback timer を張る
|
|
450
|
+
*/
|
|
451
|
+
forceClose() {
|
|
452
|
+
const socket = this.ws;
|
|
453
|
+
if (socket === null) return;
|
|
454
|
+
this.armCloseFallback();
|
|
455
|
+
this.closeSocket(socket);
|
|
456
|
+
}
|
|
457
|
+
armCloseFallback() {
|
|
458
|
+
if (this.closeFallbackTimer !== null) return;
|
|
459
|
+
const handle = attempt(() => this.props.deps.setTimeout(() => this.onCloseFallback(), CLOSE_FALLBACK_MS));
|
|
460
|
+
if (handle instanceof Error) {
|
|
461
|
+
this.log.error({
|
|
462
|
+
action: "ws.close.fallback.schedule.error",
|
|
463
|
+
message: safeErrorMessage({ error: handle }),
|
|
464
|
+
error: handle
|
|
465
|
+
});
|
|
466
|
+
this.onCloseFallback();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
this.closeFallbackTimer = handle;
|
|
470
|
+
}
|
|
471
|
+
clearCloseFallback() {
|
|
472
|
+
if (this.closeFallbackTimer === null) return;
|
|
473
|
+
const handle = this.closeFallbackTimer;
|
|
474
|
+
this.closeFallbackTimer = null;
|
|
475
|
+
const result = attempt(() => this.props.deps.clearTimeout(handle));
|
|
476
|
+
if (result instanceof Error) this.log.error({
|
|
477
|
+
action: "ws.close.fallback.clear.error",
|
|
478
|
+
message: safeErrorMessage({ error: result }),
|
|
479
|
+
error: result
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
onCloseFallback() {
|
|
483
|
+
this.closeFallbackTimer = null;
|
|
484
|
+
if (this.closeHandled || this.isStoppedFlag) return;
|
|
485
|
+
this.log.warn({
|
|
486
|
+
action: "ws.close.fallback",
|
|
487
|
+
message: `close event not delivered within ${CLOSE_FALLBACK_MS}ms — synthesizing teardown`
|
|
488
|
+
});
|
|
489
|
+
this.settleClose({ code: null });
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* close teardown の唯一の入口。実 close イベントと fallback の合成 close が競合しても
|
|
493
|
+
* `closeHandled` で一度だけ実行する
|
|
494
|
+
*/
|
|
495
|
+
settleClose(props) {
|
|
496
|
+
if (this.closeHandled) return;
|
|
497
|
+
this.closeHandled = true;
|
|
498
|
+
this.clearCloseFallback();
|
|
499
|
+
this.disarmIdleWatchdog();
|
|
500
|
+
this.ws = null;
|
|
501
|
+
if (this.hasConnected) this.props.onDisconnected();
|
|
502
|
+
if (this.pendingResolved) return;
|
|
503
|
+
const error = props.code === null ? new FlumeConnectionError("WebSocket closed before hello (code=none)") : new FlumeConnectionError(`WebSocket closed before hello (code=${props.code})`, { code: props.code });
|
|
504
|
+
this.completeConnect(error);
|
|
505
|
+
}
|
|
356
506
|
safeOnMessage(ev, socket) {
|
|
357
507
|
const r = attempt(() => this.onMessage(String(ev.data), socket));
|
|
358
508
|
if (r instanceof Error) this.log.error({
|
|
@@ -378,6 +528,7 @@ var FlumeSlackSocketMode = class {
|
|
|
378
528
|
});
|
|
379
529
|
}
|
|
380
530
|
onMessage(raw, socket) {
|
|
531
|
+
this.lastFrameAt = safeNow({ deps: this.props.deps });
|
|
381
532
|
if (this.isStoppedFlag) return;
|
|
382
533
|
const json = safeJsonParse(raw);
|
|
383
534
|
if (json instanceof FlumeParseError) {
|
|
@@ -402,7 +553,6 @@ var FlumeSlackSocketMode = class {
|
|
|
402
553
|
message: `type=${typeof json.type === "string" ? json.type : "-"} length=${raw.length}`,
|
|
403
554
|
detail: { length: raw.length }
|
|
404
555
|
});
|
|
405
|
-
this.lastFrameAt = this.props.deps.now();
|
|
406
556
|
if (json.type === "hello") {
|
|
407
557
|
this.log.info({
|
|
408
558
|
action: "socket.hello",
|
|
@@ -421,7 +571,7 @@ var FlumeSlackSocketMode = class {
|
|
|
421
571
|
message: `reason=${reason}`,
|
|
422
572
|
detail: { reason }
|
|
423
573
|
});
|
|
424
|
-
this.
|
|
574
|
+
this.forceClose();
|
|
425
575
|
return;
|
|
426
576
|
}
|
|
427
577
|
if (typeof json.envelope_id === "string") {
|
|
@@ -466,13 +616,14 @@ var FlumeSlackSocketMode = class {
|
|
|
466
616
|
reason: ev.reason
|
|
467
617
|
}
|
|
468
618
|
});
|
|
469
|
-
this.
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
619
|
+
if (this.closeHandled) {
|
|
620
|
+
this.log.debug({
|
|
621
|
+
action: "ws.close.late",
|
|
622
|
+
message: "close event arrived after synthesized teardown, ignoring"
|
|
623
|
+
});
|
|
624
|
+
return;
|
|
475
625
|
}
|
|
626
|
+
this.settleClose({ code: ev.code });
|
|
476
627
|
}
|
|
477
628
|
onError() {
|
|
478
629
|
const error = new FlumeConnectionError("WebSocket connection error");
|
|
@@ -525,6 +676,19 @@ var FlumeSlackSocketMode = class {
|
|
|
525
676
|
//#region lib/slack/slack-source.ts
|
|
526
677
|
const SEEN_CACHE_MAX = 1024;
|
|
527
678
|
const SEEN_CACHE_TTL_MS = 300 * 1e3;
|
|
679
|
+
/**
|
|
680
|
+
* `apps.connections.open` が返す恒久エラー。トークンが無効な状態で再接続しても
|
|
681
|
+
* 回復しないため、reconnect を打ち切って呼び出し側へエラーを返す
|
|
682
|
+
*/
|
|
683
|
+
const TERMINAL_SLACK_ERROR_CODES = new Set([
|
|
684
|
+
"invalid_auth",
|
|
685
|
+
"account_inactive",
|
|
686
|
+
"token_revoked",
|
|
687
|
+
"not_authed",
|
|
688
|
+
"not_allowed_token_type",
|
|
689
|
+
"token_expired",
|
|
690
|
+
"missing_scope"
|
|
691
|
+
]);
|
|
528
692
|
var FlumeSlackSource = class extends FlumeSource {
|
|
529
693
|
options;
|
|
530
694
|
name = "slack";
|
|
@@ -587,9 +751,10 @@ var FlumeSlackSource = class extends FlumeSource {
|
|
|
587
751
|
}
|
|
588
752
|
async connectInternal(ctx) {
|
|
589
753
|
this.setStatus("connecting");
|
|
590
|
-
|
|
754
|
+
const socket = new FlumeSlackSocketMode({
|
|
591
755
|
appToken: this.options.appToken,
|
|
592
756
|
idleTimeoutMs: this.options.idleTimeoutMs,
|
|
757
|
+
handshakeTimeoutMs: this.options.handshakeTimeoutMs,
|
|
593
758
|
onLog: ctx.log.handler,
|
|
594
759
|
deps: ctx.deps,
|
|
595
760
|
onMessage: (envelope) => this.handleMessage(ctx, envelope),
|
|
@@ -602,43 +767,64 @@ var FlumeSlackSource = class extends FlumeSource {
|
|
|
602
767
|
this.setStatus("connected");
|
|
603
768
|
},
|
|
604
769
|
onDisconnected: () => {
|
|
605
|
-
if (
|
|
770
|
+
if (socket.isStopped) {
|
|
606
771
|
this.setStatus("disconnected");
|
|
607
772
|
return;
|
|
608
773
|
}
|
|
609
774
|
this.scheduleReconnect(ctx);
|
|
610
775
|
}
|
|
611
776
|
});
|
|
612
|
-
|
|
777
|
+
this.socket = socket;
|
|
778
|
+
const error = await socket.connect({ signal: this.internalController?.signal });
|
|
613
779
|
if (error instanceof Error) {
|
|
614
780
|
ctx.log.error({
|
|
615
781
|
action: "connect.failed",
|
|
616
782
|
message: safeErrorMessage({ error }),
|
|
617
783
|
error
|
|
618
784
|
});
|
|
619
|
-
if (this.
|
|
785
|
+
if (this.isTerminalSlackError(error)) {
|
|
786
|
+
ctx.log.error({
|
|
787
|
+
action: "reconnect.terminal",
|
|
788
|
+
message: `permanent Slack API error, not reconnecting: ${safeErrorMessage({ error })}`,
|
|
789
|
+
error
|
|
790
|
+
});
|
|
791
|
+
this.setStatus("disconnected");
|
|
792
|
+
return error;
|
|
793
|
+
}
|
|
794
|
+
if (socket.isStopped || !this.reconnector || this.reconnector.aborted) {
|
|
620
795
|
this.setStatus("disconnected");
|
|
621
796
|
return error;
|
|
622
797
|
}
|
|
623
|
-
this.scheduleReconnect(ctx);
|
|
798
|
+
this.scheduleReconnect(ctx, this.minRetryDelayMs(error));
|
|
624
799
|
}
|
|
625
800
|
return null;
|
|
626
801
|
}
|
|
802
|
+
isTerminalSlackError(error) {
|
|
803
|
+
if (!(error instanceof FlumeHttpError)) return false;
|
|
804
|
+
if (error.code === null) return false;
|
|
805
|
+
return TERMINAL_SLACK_ERROR_CODES.has(error.code);
|
|
806
|
+
}
|
|
807
|
+
minRetryDelayMs(error) {
|
|
808
|
+
if (!(error instanceof FlumeHttpError)) return void 0;
|
|
809
|
+
return error.retryAfterMs ?? void 0;
|
|
810
|
+
}
|
|
627
811
|
handleMessage(ctx, envelope) {
|
|
628
812
|
const seen = this.seen;
|
|
629
813
|
if (!seen) return;
|
|
630
|
-
|
|
814
|
+
const dedupKey = this.toDedupKey(envelope);
|
|
815
|
+
if (seen.has(dedupKey)) {
|
|
631
816
|
ctx.log.debug({
|
|
632
817
|
action: "dedup.skip",
|
|
633
|
-
message: `duplicate
|
|
818
|
+
message: `duplicate key=${dedupKey}`,
|
|
634
819
|
detail: {
|
|
820
|
+
dedup_key: dedupKey,
|
|
635
821
|
envelope_id: envelope.envelope_id,
|
|
636
822
|
retry_attempt: envelope.retry_attempt
|
|
637
823
|
}
|
|
638
824
|
});
|
|
639
825
|
return;
|
|
640
826
|
}
|
|
641
|
-
seen.add(
|
|
827
|
+
seen.add(dedupKey);
|
|
642
828
|
seen.trim();
|
|
643
829
|
this.emit({
|
|
644
830
|
source: "slack",
|
|
@@ -648,6 +834,15 @@ var FlumeSlackSource = class extends FlumeSource {
|
|
|
648
834
|
receivedAt: safeNow({ deps: ctx.deps })
|
|
649
835
|
});
|
|
650
836
|
}
|
|
837
|
+
/**
|
|
838
|
+
* Events API の再配送は envelope_id が変わり得るため、payload.event_id があれば
|
|
839
|
+
* そちらを重複判定キーとして優先する (再配送をまたいで安定な識別子)
|
|
840
|
+
*/
|
|
841
|
+
toDedupKey(envelope) {
|
|
842
|
+
const eventId = envelope.payload.event_id;
|
|
843
|
+
if (typeof eventId === "string" && eventId.length > 0) return eventId;
|
|
844
|
+
return envelope.envelope_id;
|
|
845
|
+
}
|
|
651
846
|
safeExtractMeta(ctx, envelope) {
|
|
652
847
|
const result = attempt(() => flumeExtractSlackMeta(envelope));
|
|
653
848
|
if (result instanceof Error) {
|
|
@@ -662,11 +857,12 @@ var FlumeSlackSource = class extends FlumeSource {
|
|
|
662
857
|
}
|
|
663
858
|
return result;
|
|
664
859
|
}
|
|
665
|
-
scheduleReconnect(ctx) {
|
|
860
|
+
scheduleReconnect(ctx, minDelayMs) {
|
|
666
861
|
scheduleFlumeReconnect({
|
|
667
862
|
reconnector: this.reconnector,
|
|
668
863
|
log: ctx.log,
|
|
669
864
|
setStatus: (status) => this.setStatus(status),
|
|
865
|
+
minDelayMs,
|
|
670
866
|
retry: () => {
|
|
671
867
|
this.connectInternal(ctx).catch((err) => {
|
|
672
868
|
const error = safeNormalizeError({ value: err });
|
package/dist/time.d.ts
CHANGED
|
@@ -1,5 +1,58 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { O as FlumeSourceStartContext, R as FlumeTimeSourceOptions, n as FlumeCatchupPolicy, t as FlumeSource } from "./flume-source.js";
|
|
2
|
+
import { t as FlumeParseError } from "./parse-error.js";
|
|
2
3
|
|
|
4
|
+
//#region lib/time/time-source.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* cron スケジュールで tick を emit する Source。外部接続を持たないため
|
|
7
|
+
* 起動成功と同時に `connected` になり reconnect の対象外。
|
|
8
|
+
*
|
|
9
|
+
* options.statePersister + options.catchupPolicy を渡すと:
|
|
10
|
+
* 1. 起動時に lastFiredAt を読み出す
|
|
11
|
+
* 2. lastFiredAt から now までの過ぎ去った cron マッチを policy に従って再発火する
|
|
12
|
+
* 3. 各 tick 後に lastFiredAt を順番に保存する (tick をブロックせず、停止時に完了を待つ)
|
|
13
|
+
*
|
|
14
|
+
* 保存先や形式は flume の関知ではなく statePersister の実装が決める (純粋 DI)。
|
|
15
|
+
*
|
|
16
|
+
* DST 制限: fall-back (時計の巻き戻し) の二重発火は dedup で防ぐが、spring-forward
|
|
17
|
+
* (時計の飛び越し) でスキップされた壁時計時刻 (例: 02:30 が存在しない日) にスケジュール
|
|
18
|
+
* された job はその日は実行されない。cron は壁時計 (local time) 基準のため仕様とする
|
|
19
|
+
*/
|
|
20
|
+
declare class FlumeTimeSource extends FlumeSource {
|
|
21
|
+
private readonly options;
|
|
22
|
+
readonly name: "time";
|
|
23
|
+
private scheduler;
|
|
24
|
+
private readonly loadCancelled;
|
|
25
|
+
private readonly saveQueue;
|
|
26
|
+
constructor(options: FlumeTimeSourceOptions);
|
|
27
|
+
protected connect(ctx: FlumeSourceStartContext): Promise<Error | null>;
|
|
28
|
+
protected disconnect(): Promise<void>;
|
|
29
|
+
private handleTick;
|
|
30
|
+
private emitTick;
|
|
31
|
+
private runCatchup;
|
|
32
|
+
/** スケジューラが cron エラーで恒久停止した (dead-but-green を防ぐため接続状態を落とす) */
|
|
33
|
+
private handleSchedulerHalt;
|
|
34
|
+
private loadLastFiredAt;
|
|
35
|
+
private saveLastFiredAt;
|
|
36
|
+
private safeMessage;
|
|
37
|
+
private normalizeMeta;
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region lib/time/parse-cron.d.ts
|
|
41
|
+
type FlumeCron = {
|
|
42
|
+
source: string;
|
|
43
|
+
minutes: ReadonlySet<number>;
|
|
44
|
+
hours: ReadonlySet<number>;
|
|
45
|
+
daysOfMonth: ReadonlySet<number>;
|
|
46
|
+
months: ReadonlySet<number>;
|
|
47
|
+
daysOfWeek: ReadonlySet<number>; /** day-of-month フィールドが wildcard を含まないか。dow と両方制限時は OR マッチ */
|
|
48
|
+
domRestricted: boolean;
|
|
49
|
+
dowRestricted: boolean;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* 5 フィールド cron 式をパースする。dow は 0-7 を許可し 7 を 0 (日曜) に正規化する
|
|
53
|
+
*/
|
|
54
|
+
declare function parseCron(expression: string): FlumeCron | FlumeParseError;
|
|
55
|
+
//#endregion
|
|
3
56
|
//#region lib/time/cron-next.d.ts
|
|
4
57
|
/**
|
|
5
58
|
* `afterMs` より後の最初の cron マッチ時刻 (epoch ms) を壁時計 (local time) で求める。
|
|
@@ -7,4 +60,30 @@ import { i as FlumeParseError, n as parseCron, r as FlumeTimeSource, t as FlumeC
|
|
|
7
60
|
*/
|
|
8
61
|
declare function flumeCronNext(cron: FlumeCron, afterMs: number): number | FlumeParseError;
|
|
9
62
|
//#endregion
|
|
10
|
-
|
|
63
|
+
//#region lib/time/time-catchup.d.ts
|
|
64
|
+
type Props = {
|
|
65
|
+
cron: FlumeCron;
|
|
66
|
+
lastFiredAt: number;
|
|
67
|
+
now: number;
|
|
68
|
+
policy: FlumeCatchupPolicy;
|
|
69
|
+
};
|
|
70
|
+
type FlumeCatchupMatches = {
|
|
71
|
+
matches: ReadonlyArray<number>; /** missed: 上限超過で古いマッチを切り捨てた / lastOnly: 反復上限で now まで走査しきれなかった */
|
|
72
|
+
truncated: boolean;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* `lastFiredAt` から `now` までに過ぎ去った cron マッチを policy に従って列挙する。
|
|
76
|
+
*
|
|
77
|
+
* - policy.mode === "off" : 常に空
|
|
78
|
+
* - policy.mode === "lastOnly" : 過ぎ去ったマッチの中で最も新しいもの 1 件 (件数上限なし・O(1) メモリ)
|
|
79
|
+
* - policy.mode === "missed" : maxWindowMs (既定 24h) 以内に過ぎ去ったすべてのマッチ。
|
|
80
|
+
* window の起点は `max(lastFiredAt, now - maxWindowMs)`。
|
|
81
|
+
* 10,000 件を超えた場合は古い方を捨てて新しい 10,000 件を返し
|
|
82
|
+
* truncated: true で通知する
|
|
83
|
+
*
|
|
84
|
+
* 到達不能 cron や catastrophic な policy ミス指定の場合は FlumeParseError を返す
|
|
85
|
+
* (catchup 列挙だけで失敗させる。source 本体の起動は別判断)
|
|
86
|
+
*/
|
|
87
|
+
declare function flumeCollectCatchupMatches(props: Props): FlumeCatchupMatches | FlumeParseError;
|
|
88
|
+
//#endregion
|
|
89
|
+
export { type FlumeCron, FlumeTimeSource, flumeCollectCatchupMatches, flumeCronNext, parseCron };
|