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