@interactive-inc/flume 0.10.1 → 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 +90 -30
- package/dist/discord.js +3 -3
- package/dist/flume-source.d.ts +5 -1
- package/dist/flume-source.js +39 -57
- package/dist/github.js +7 -9
- package/dist/index.d.ts +143 -37
- package/dist/index.js +183 -98
- package/dist/safe-json-parse.js +1 -1
- package/dist/safe-read-text.js +17 -3
- package/dist/safe-stringify.js +17 -153
- package/dist/schedule-reconnect.js +153 -0
- package/dist/slack.js +4 -5
- package/dist/time.d.ts +4 -2
- package/dist/time.js +17 -13
- package/package.json +11 -4
- package/dist/connection-error.js +0 -16
- package/dist/http-error.js +0 -16
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { a as safeInvokeCallback, d as safeErrorMessage, l as attempt } from "./flume-source.js";
|
|
2
|
+
import { n as safeRandom, r as FlumeConnectionError } from "./safe-stringify.js";
|
|
3
|
+
//#region lib/reconnector.ts
|
|
4
|
+
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
5
|
+
/**
|
|
6
|
+
* 指数バックオフ + ジッタ付きの再接続スケジューラ。
|
|
7
|
+
* setTimeout コールバック内のユーザー fn が throw / reject しても reconnect ループは止めない。
|
|
8
|
+
* `generation` は clearTimeout が throw して古い timer が生き残った場合でも
|
|
9
|
+
* stale な発火を無視するための世代トークン
|
|
10
|
+
*/
|
|
11
|
+
var FlumeReconnector = class {
|
|
12
|
+
props;
|
|
13
|
+
currentAttempt = 0;
|
|
14
|
+
isAborted = false;
|
|
15
|
+
timer = null;
|
|
16
|
+
generation = 0;
|
|
17
|
+
constructor(props) {
|
|
18
|
+
this.props = props;
|
|
19
|
+
}
|
|
20
|
+
get attempt() {
|
|
21
|
+
return this.currentAttempt;
|
|
22
|
+
}
|
|
23
|
+
get aborted() {
|
|
24
|
+
return this.isAborted;
|
|
25
|
+
}
|
|
26
|
+
schedule(fn, options) {
|
|
27
|
+
if (this.isAborted) return { kind: "refused" };
|
|
28
|
+
if (this.currentAttempt >= this.props.maxAttempts) {
|
|
29
|
+
this.generation++;
|
|
30
|
+
this.clearTimer();
|
|
31
|
+
return { kind: "exhausted" };
|
|
32
|
+
}
|
|
33
|
+
this.clearTimer();
|
|
34
|
+
const delay = this.computeDelay(options?.minDelayMs ?? 0);
|
|
35
|
+
const scheduledGeneration = ++this.generation;
|
|
36
|
+
const timerResult = attempt(() => this.props.deps.setTimeout(() => this.runRetry(fn, scheduledGeneration), delay));
|
|
37
|
+
if (timerResult instanceof Error) {
|
|
38
|
+
this.props.log.error({
|
|
39
|
+
action: "reconnect.timer.schedule.error",
|
|
40
|
+
message: safeErrorMessage({ error: timerResult }),
|
|
41
|
+
error: timerResult
|
|
42
|
+
});
|
|
43
|
+
this.timer = null;
|
|
44
|
+
return { kind: "refused" };
|
|
45
|
+
}
|
|
46
|
+
this.currentAttempt++;
|
|
47
|
+
this.timer = timerResult;
|
|
48
|
+
return {
|
|
49
|
+
kind: "scheduled",
|
|
50
|
+
delayMs: delay
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
reset() {
|
|
54
|
+
this.currentAttempt = 0;
|
|
55
|
+
}
|
|
56
|
+
cancel() {
|
|
57
|
+
this.isAborted = true;
|
|
58
|
+
this.generation++;
|
|
59
|
+
this.clearTimer();
|
|
60
|
+
}
|
|
61
|
+
runRetry(fn, scheduledGeneration) {
|
|
62
|
+
if (this.isAborted) return;
|
|
63
|
+
if (scheduledGeneration !== this.generation) return;
|
|
64
|
+
this.timer = null;
|
|
65
|
+
safeInvokeCallback({
|
|
66
|
+
fn,
|
|
67
|
+
onError: (error) => {
|
|
68
|
+
this.props.log.error({
|
|
69
|
+
action: "reconnect.timer.error",
|
|
70
|
+
message: safeErrorMessage({ error }),
|
|
71
|
+
error
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
clearTimer() {
|
|
77
|
+
if (this.timer === null) return;
|
|
78
|
+
const handle = this.timer;
|
|
79
|
+
const result = attempt(() => this.props.deps.clearTimeout(handle));
|
|
80
|
+
if (result instanceof Error) this.props.log.error({
|
|
81
|
+
action: "reconnect.timer.clear.error",
|
|
82
|
+
message: safeErrorMessage({ error: result }),
|
|
83
|
+
error: result
|
|
84
|
+
});
|
|
85
|
+
this.timer = null;
|
|
86
|
+
}
|
|
87
|
+
computeDelay(minDelayMs) {
|
|
88
|
+
const jittered = Math.min(this.props.baseDelay * 2 ** this.currentAttempt, this.props.maxDelay) * (.5 + safeRandom({ deps: this.props.deps }) * .5);
|
|
89
|
+
return Math.min(Math.max(jittered, Number.isFinite(minDelayMs) && minDelayMs > 0 ? minDelayMs : 0), MAX_TIMER_DELAY_MS);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region lib/schedule-reconnect.ts
|
|
94
|
+
/**
|
|
95
|
+
* 接続が落ちた際の共通再接続スケジューラ。
|
|
96
|
+
* 再接続の設定状況 (無効 / 中止 / 試行尽き / timer 拒否) を見極めてからステータス遷移する。
|
|
97
|
+
* - reconnector が無ければ reconnect.disabled を info ログし disconnected へ
|
|
98
|
+
* - cancel 済みなら reconnect.aborted を info ログし disconnected へ
|
|
99
|
+
* - schedule() が exhausted なら reconnect.exhausted を error ログし disconnected へ
|
|
100
|
+
* - schedule() が refused (timer 予約失敗) なら reconnect.refused を error ログし disconnected へ
|
|
101
|
+
* ("reconnecting" のまま発火しない timer を待ち続けるハングを防ぐ)
|
|
102
|
+
* - scheduled なら reconnecting へ遷移し reconnect.scheduled を info ログ
|
|
103
|
+
*/
|
|
104
|
+
function scheduleFlumeReconnect(props) {
|
|
105
|
+
if (!props.reconnector) {
|
|
106
|
+
props.log.info({
|
|
107
|
+
action: "reconnect.disabled",
|
|
108
|
+
message: "reconnect is disabled, staying disconnected"
|
|
109
|
+
});
|
|
110
|
+
props.setStatus("disconnected");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (props.reconnector.aborted) {
|
|
114
|
+
props.log.info({
|
|
115
|
+
action: "reconnect.aborted",
|
|
116
|
+
message: "reconnector cancelled, staying disconnected"
|
|
117
|
+
});
|
|
118
|
+
props.setStatus("disconnected");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const schedule = props.reconnector.schedule(props.retry, { minDelayMs: props.minDelayMs });
|
|
122
|
+
if (schedule.kind === "exhausted") {
|
|
123
|
+
const error = new FlumeConnectionError(`reconnect exhausted after ${props.reconnector.attempt} attempts`);
|
|
124
|
+
props.log.error({
|
|
125
|
+
action: "reconnect.exhausted",
|
|
126
|
+
message: safeErrorMessage({ error }),
|
|
127
|
+
error
|
|
128
|
+
});
|
|
129
|
+
props.setStatus("disconnected");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (schedule.kind === "refused") {
|
|
133
|
+
const error = new FlumeConnectionError("reconnect timer could not be scheduled");
|
|
134
|
+
props.log.error({
|
|
135
|
+
action: "reconnect.refused",
|
|
136
|
+
message: safeErrorMessage({ error }),
|
|
137
|
+
error
|
|
138
|
+
});
|
|
139
|
+
props.setStatus("disconnected");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
props.setStatus("reconnecting");
|
|
143
|
+
props.log.info({
|
|
144
|
+
action: "reconnect.scheduled",
|
|
145
|
+
message: `next attempt in ${Math.round(schedule.delayMs)}ms`,
|
|
146
|
+
detail: {
|
|
147
|
+
attempt: props.reconnector.attempt,
|
|
148
|
+
delayMs: Math.round(schedule.delayMs)
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
//#endregion
|
|
153
|
+
export { FlumeReconnector as n, scheduleFlumeReconnect as t };
|
package/dist/slack.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { n as scheduleFlumeReconnect, r as FlumeReconnector, t as safeStringify } from "./safe-stringify.js";
|
|
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";
|
|
5
4
|
import { t as isRecord } from "./is-record.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) {
|
package/dist/time.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { t as FlumeParseError } from "./parse-error.js";
|
|
|
9
9
|
* options.statePersister + options.catchupPolicy を渡すと:
|
|
10
10
|
* 1. 起動時に lastFiredAt を読み出す
|
|
11
11
|
* 2. lastFiredAt から now までの過ぎ去った cron マッチを policy に従って再発火する
|
|
12
|
-
* 3. 各 tick 後に lastFiredAt
|
|
12
|
+
* 3. 各 tick 後に lastFiredAt を順番に保存する (tick をブロックせず、停止時に完了を待つ)
|
|
13
13
|
*
|
|
14
14
|
* 保存先や形式は flume の関知ではなく statePersister の実装が決める (純粋 DI)。
|
|
15
15
|
*
|
|
@@ -21,9 +21,11 @@ declare class FlumeTimeSource extends FlumeSource {
|
|
|
21
21
|
private readonly options;
|
|
22
22
|
readonly name: "time";
|
|
23
23
|
private scheduler;
|
|
24
|
+
private readonly loadCancelled;
|
|
25
|
+
private readonly saveQueue;
|
|
24
26
|
constructor(options: FlumeTimeSourceOptions);
|
|
25
27
|
protected connect(ctx: FlumeSourceStartContext): Promise<Error | null>;
|
|
26
|
-
protected disconnect(): void
|
|
28
|
+
protected disconnect(): Promise<void>;
|
|
27
29
|
private handleTick;
|
|
28
30
|
private emitTick;
|
|
29
31
|
private runCatchup;
|
package/dist/time.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as safeInvokeCallback, c as FlumeParseError, d as safeErrorMessage, i as safeNow, l as attempt, n as FlumeSerialQueue, r as FlumeLogger, s as FlumeStartError, t as FlumeSource, u as safeNormalizeError } from "./flume-source.js";
|
|
2
2
|
import { t as isRecord } from "./is-record.js";
|
|
3
3
|
//#region lib/time/parse-cron-field.ts
|
|
4
4
|
/**
|
|
@@ -437,7 +437,7 @@ function hasMatchBefore(cron, windowStart, recentStart) {
|
|
|
437
437
|
* options.statePersister + options.catchupPolicy を渡すと:
|
|
438
438
|
* 1. 起動時に lastFiredAt を読み出す
|
|
439
439
|
* 2. lastFiredAt から now までの過ぎ去った cron マッチを policy に従って再発火する
|
|
440
|
-
* 3. 各 tick 後に lastFiredAt
|
|
440
|
+
* 3. 各 tick 後に lastFiredAt を順番に保存する (tick をブロックせず、停止時に完了を待つ)
|
|
441
441
|
*
|
|
442
442
|
* 保存先や形式は flume の関知ではなく statePersister の実装が決める (純粋 DI)。
|
|
443
443
|
*
|
|
@@ -449,6 +449,8 @@ var FlumeTimeSource = class extends FlumeSource {
|
|
|
449
449
|
options;
|
|
450
450
|
name = "time";
|
|
451
451
|
scheduler = null;
|
|
452
|
+
loadCancelled = Promise.withResolvers();
|
|
453
|
+
saveQueue = new FlumeSerialQueue();
|
|
452
454
|
constructor(options) {
|
|
453
455
|
super();
|
|
454
456
|
this.options = options;
|
|
@@ -468,6 +470,7 @@ var FlumeTimeSource = class extends FlumeSource {
|
|
|
468
470
|
}
|
|
469
471
|
const persister = this.options.statePersister ?? null;
|
|
470
472
|
const lastFiredAt = persister === null ? null : await this.loadLastFiredAt(ctx, persister);
|
|
473
|
+
if (this.isStopped) return new FlumeStartError("Time source: stopped during state load");
|
|
471
474
|
this.scheduler = new FlumeTimeScheduler({
|
|
472
475
|
cron,
|
|
473
476
|
onLog: ctx.log.handler,
|
|
@@ -492,9 +495,11 @@ var FlumeTimeSource = class extends FlumeSource {
|
|
|
492
495
|
});
|
|
493
496
|
return null;
|
|
494
497
|
}
|
|
495
|
-
disconnect() {
|
|
498
|
+
async disconnect() {
|
|
499
|
+
this.loadCancelled.resolve(null);
|
|
496
500
|
this.scheduler?.stop();
|
|
497
501
|
this.scheduler = null;
|
|
502
|
+
await this.saveQueue.drain();
|
|
498
503
|
}
|
|
499
504
|
handleTick(ctx, firedAt, persister) {
|
|
500
505
|
this.emitTick(ctx, firedAt);
|
|
@@ -565,7 +570,7 @@ var FlumeTimeSource = class extends FlumeSource {
|
|
|
565
570
|
this.setStatus("disconnected", "scheduler halted");
|
|
566
571
|
}
|
|
567
572
|
async loadLastFiredAt(ctx, persister) {
|
|
568
|
-
const result = await attempt(() => persister.load());
|
|
573
|
+
const result = await Promise.race([attempt(() => persister.load()), this.loadCancelled.promise]);
|
|
569
574
|
if (result instanceof Error) {
|
|
570
575
|
ctx.log.warn({
|
|
571
576
|
action: "time.state.load.error",
|
|
@@ -579,15 +584,14 @@ var FlumeTimeSource = class extends FlumeSource {
|
|
|
579
584
|
return result.lastFiredAt;
|
|
580
585
|
}
|
|
581
586
|
saveLastFiredAt(ctx, persister, lastFiredAt) {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
}
|
|
587
|
+
this.saveQueue.add(async () => {
|
|
588
|
+
const error = await attempt(() => persister.save({ lastFiredAt }));
|
|
589
|
+
if (!(error instanceof Error)) return;
|
|
590
|
+
ctx.log.warn({
|
|
591
|
+
action: "time.state.save.error",
|
|
592
|
+
message: safeErrorMessage({ error }),
|
|
593
|
+
error
|
|
594
|
+
});
|
|
591
595
|
});
|
|
592
596
|
}
|
|
593
597
|
safeMessage(ctx, tick) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@interactive-inc/flume",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Unified notification listener for Discord, Slack, and GitHub. Raw WebSocket + fetch + Zod. No SDK dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"discord",
|
|
@@ -76,9 +76,16 @@
|
|
|
76
76
|
"zod": "^4.4.3"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
|
-
"typescript": "^5.
|
|
80
|
-
"vite-plus": "^0.1.
|
|
81
|
-
"vitest": "^4.1.
|
|
79
|
+
"typescript": "^5.9.3",
|
|
80
|
+
"vite-plus": "^0.1.24",
|
|
81
|
+
"vitest": "^4.1.10"
|
|
82
|
+
},
|
|
83
|
+
"devEngines": {
|
|
84
|
+
"packageManager": {
|
|
85
|
+
"name": "bun",
|
|
86
|
+
"version": "1.3.14",
|
|
87
|
+
"onFail": "download"
|
|
88
|
+
}
|
|
82
89
|
},
|
|
83
90
|
"engines": {
|
|
84
91
|
"node": ">=22"
|
package/dist/connection-error.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
//#region lib/errors/connection-error.ts
|
|
2
|
-
/**
|
|
3
|
-
* 接続失敗を表す。Discord Gateway / Slack Socket Mode / その他 WebSocket 系の close で発生。
|
|
4
|
-
* `code` は接続が落ちた際の close code (Discord は 4xxx 帯が再接続可否を示す)
|
|
5
|
-
*/
|
|
6
|
-
var FlumeConnectionError = class extends Error {
|
|
7
|
-
code;
|
|
8
|
-
constructor(message, options) {
|
|
9
|
-
super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
|
|
10
|
-
this.name = "FlumeConnectionError";
|
|
11
|
-
this.code = options?.code ?? null;
|
|
12
|
-
Object.freeze(this);
|
|
13
|
-
}
|
|
14
|
-
};
|
|
15
|
-
//#endregion
|
|
16
|
-
export { FlumeConnectionError as t };
|
package/dist/http-error.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
//#region lib/errors/http-error.ts
|
|
2
|
-
var FlumeHttpError = class extends Error {
|
|
3
|
-
status;
|
|
4
|
-
code;
|
|
5
|
-
retryAfterMs;
|
|
6
|
-
constructor(props) {
|
|
7
|
-
super(props.message, props.cause === void 0 ? void 0 : { cause: props.cause });
|
|
8
|
-
this.name = "FlumeHttpError";
|
|
9
|
-
this.status = props.status;
|
|
10
|
-
this.code = props.code ?? null;
|
|
11
|
-
this.retryAfterMs = props.retryAfterMs ?? null;
|
|
12
|
-
Object.freeze(this);
|
|
13
|
-
}
|
|
14
|
-
};
|
|
15
|
-
//#endregion
|
|
16
|
-
export { FlumeHttpError as t };
|