@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.
@@ -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 { 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
- import { t as FlumeConnectionError } from "./connection-error.js";
3
- import { t as FlumeHttpError } from "./http-error.js";
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 safeReadText } from "./safe-read-text.js";
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 を保存する (best-effort, ブロックしない)
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 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";
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 を保存する (best-effort, ブロックしない)
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
- safeInvokeCallback({
583
- fn: () => persister.save({ lastFiredAt }),
584
- onError: (error) => {
585
- ctx.log.warn({
586
- action: "time.state.save.error",
587
- message: safeErrorMessage({ error: safeNormalizeError({ value: error }) }),
588
- error
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.10.1",
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.6.0",
80
- "vite-plus": "^0.1.21",
81
- "vitest": "^4.1.9"
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"
@@ -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 };
@@ -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 };