@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 CHANGED
@@ -48,6 +48,7 @@ if (running instanceof Error) throw running
48
48
 
49
49
  // later
50
50
  await running.close()
51
+ await running.drain() // outside callbacks: finish accepted callback work and diagnostics
51
52
  ```
52
53
 
53
54
  `new Flume({ sources, ...options })` — a single options object; `sources` is the only required field, everything else is optional. There is one unified firehose: `onEvent` (push) and `FlumeRunning.stream()` (pull) both deliver the same `FlumeStreamItem` — received events **and** every log (status transitions, errors, debug) merged into one stream. The consumer filters by `item.kind` (`"event"` / `"log"`) and `item.log.level`. This is built for piping the whole picture into an agent (Claude / Codex) so it notices disconnects on its own. `onError` is a convenience filter that additionally receives only the `level: "error"` logs (route it straight to Sentry).
@@ -70,8 +71,10 @@ Flume ──open()──▶ FlumeRunning ──close()──▶ FlumeClosed
70
71
  ```
71
72
 
72
73
  - `Flume.open()` returns `FlumeRunning | FlumeStartError`. Branch with `instanceof Error`. On partial failure (one source fails while another succeeds), the already-opened sources are rolled back and a `FlumeStartError` is returned with per-source detail in `.message`. Calling `open()` a second time on the same `Flume` instance returns `FlumeStartError` at runtime — the type system also rejects calling `open()` on the returned `FlumeRunning`/`FlumeClosed` handles.
73
- - `FlumeRunning.close()` returns a `FlumeClosed` snapshot. `close()` is idempotent and concurrent-safe.
74
- - `FlumeClosed` exposes only `statuses()` a frozen snapshot of each source's final state. No `open`, no `close`, no leaking source references.
74
+ - `FlumeRunning.close()` waits for sources to stop and returns a `FlumeClosed` snapshot. It is idempotent and concurrent-safe, and can be awaited inside `onEvent` or `onError`.
75
+ - `FlumeRunning.drain()` waits for accepted callbacks and their failure diagnostics. Call it **outside callbacks**, normally after `close()`, when shutdown must also finish callback work. Calling it inside a callback would wait for that callback itself.
76
+ - `open()` and `close()` do not wait for observation callbacks. A slow callback cannot hold startup or source shutdown open. Callback delivery remains serial; accepted callbacks may finish after `close()` returns.
77
+ - `FlumeClosed` exposes `statuses()` and `errors()` — frozen snapshots of final source states and disconnect failures. No `open`, no `close`, no leaking source references.
75
78
  - An `AbortSignal` on `Flume` drives an automatic transition to `FlumeClosed`.
76
79
  - `FlumeRunning.kind === "running"` and `FlumeClosed.kind === "closed"` provide a runtime discriminator when generic code holds the union.
77
80
 
@@ -89,8 +92,11 @@ const closed = await running.close()
89
92
  closed.close() // type error
90
93
  closed.open() // type error
91
94
  closed.statuses() // [{ source: "discord", status: "disconnected" }, ...]
95
+ await running.drain() // finish callback delivery before exiting the host process
92
96
  ```
93
97
 
98
+ Migration from 0.10.1: shutdown code that relied on `close()` waiting for `onEvent` / `onError` should now use `await running.close(); await running.drain()` outside those callbacks. A callback may use `await running.close()` on its own. Pull streams finish after the accepted callbacks and their failure diagnostics have drained. A callback that never settles can therefore keep `drain()` and stream completion pending, while source shutdown still completes.
99
+
94
100
  ## Dynamic groups
95
101
 
96
102
  A `Flume` is single-use: its source set is fixed at construction and the FSM is terminal once closed. To add or drop sources at runtime, compose at a higher level with `FlumeConfluence` — it holds many `Flume` instances, merges all their firehoses into one `onEvent`, and lets you `add` / `remove` groups while the others keep running.
@@ -100,7 +106,7 @@ import { FlumeConfluence } from "@interactive-inc/flume"
100
106
 
101
107
  const confluence = new FlumeConfluence({
102
108
  onEvent: (item) => {
103
- if (item.kind === "event") feedToAgent(item.event)
109
+ if (item.kind === "event") feedToAgent(item.groupId, item.event)
104
110
  if (item.kind === "log" && item.log.action === "status") noticeDisconnect(item.log)
105
111
  },
106
112
  reconnect: { maxAttempts: 10 },
@@ -112,7 +118,9 @@ await confluence.remove("team-a") // stops only team-a
112
118
  await confluence.closeAll()
113
119
  ```
114
120
 
115
- `add(id, sources)` starts a fresh `Flume` for that group and returns `Error | null` (a duplicate id or a failed start is returned, never thrown). The `id` is just a management handle the merged stream itself is untagged; identify the origin via the `source` field inside each item. Each group is an independent `Flume`, so a failure in one group never rolls back another. Reconstructing a group (rather than mutating one in place) loses only its reconnect counters, dedup caches, and Discord session resume acceptable on a deliberate add/remove.
121
+ `add(id, sources)` starts a fresh `Flume` for that group and returns `Error | null` (a duplicate id or a failed start is returned, never thrown). Every merged item is stamped with that id as `groupId`, while `item.event.source` / `item.log.source` identifies the protocol source inside the group. Each group is an independent `Flume`, so a failure in one group never rolls back another. `replace(id, sources)` opens a replacement first and closes the previous group only after the new one is ready; a failed replacement leaves the previous group running.
122
+
123
+ Source instances are single-use. Reusing an existing instance returns a `FlumeStartError` and preserves its current owner's connection. `remove()` / `closeAll()` wait for source shutdown; callbacks already accepted by those groups may finish afterward.
116
124
 
117
125
  ## Sub-entries
118
126
 
@@ -126,24 +134,32 @@ import { FlumeGitHubSource } from "@interactive-inc/flume/github"
126
134
  import { FlumeTimeSource } from "@interactive-inc/flume/time"
127
135
  ```
128
136
 
129
- - `@interactive-inc/flume` — `Flume`, `FlumeConfluence`, `FlumeRunning`, `FlumeClosed`, `FlumeSource` (abstract base for third-party sources), `createFlumeDefaultDeps`, errors, types
137
+ - `@interactive-inc/flume` — `Flume`, `FlumeConfluence`, `FlumeRunning`, `FlumeClosed`, `FlumeSource` (abstract base for third-party sources), `createFlumeDefaultDeps`, errors, types, and the common `attempt` / `safe*` utilities used by custom sources
130
138
  - `@interactive-inc/flume/discord` — `FlumeDiscordSource`, `FlumeDiscordGatewayIntents`, `flumeExtractDiscordMeta`
131
139
  - `@interactive-inc/flume/slack` — `FlumeSlackSource`, `flumeExtractSlackMeta`
132
140
  - `@interactive-inc/flume/github` — `FlumeGitHubSource`, `flumeExtractGitHubMeta`
133
- - `@interactive-inc/flume/time` — `FlumeTimeSource`, `parseCron`, `flumeCronNext`
141
+ - `@interactive-inc/flume/time` — `FlumeTimeSource`, `parseCron`, `flumeCronNext`, `flumeCollectCatchupMatches`
134
142
 
135
143
  ## Custom sources
136
144
 
137
145
  Extend `FlumeSource` to plug in any protocol. The base class owns `start`/`stop`/`status`, the per-source event queue, status emission (logged on every transition), and consumed/stopped guards. You implement `connect` (open the protocol, emit events, set status) and `disconnect` (tear it down).
138
146
 
139
147
  ```ts
140
- import { FlumeSource } from "@interactive-inc/flume"
141
- import type { FlumeSourceStartContext } from "@interactive-inc/flume"
148
+ import {
149
+ attempt,
150
+ FlumeSource,
151
+ safeErrorMessage,
152
+ safeInvokeCallback,
153
+ safeJsonParse,
154
+ safeNow,
155
+ safeReadText,
156
+ } from "@interactive-inc/flume"
157
+ import type { FlumeSourceStartContext, FlumeTimerHandle } from "@interactive-inc/flume"
142
158
 
143
159
  class MyWebhookSource extends FlumeSource {
144
160
  readonly name = "my-webhook"
145
161
 
146
- private timer: ReturnType<typeof setInterval> | null = null
162
+ private timer: FlumeTimerHandle | null = null
147
163
 
148
164
  constructor(private readonly options: { url: string; pollInterval?: number }) {
149
165
  super()
@@ -152,34 +168,73 @@ class MyWebhookSource extends FlumeSource {
152
168
  protected async connect(ctx: FlumeSourceStartContext): Promise<Error | null> {
153
169
  this.setStatus("connecting")
154
170
  const interval = (this.options.pollInterval ?? 30) * 1000
155
- this.timer = ctx.deps.setInterval(() => this.poll(ctx), interval) as ReturnType<
156
- typeof setInterval
157
- >
171
+ const timer = attempt(() => ctx.deps.setInterval(() => this.runPoll(ctx), interval))
172
+ if (timer instanceof Error) return timer
173
+ this.timer = timer
158
174
  this.setStatus("connected")
159
175
  return null
160
176
  }
161
177
 
162
178
  protected disconnect(): void {
163
- if (this.timer) clearInterval(this.timer)
179
+ const timer = this.timer
164
180
  this.timer = null
181
+ const ctx = this.context
182
+ if (timer === null || ctx === null) return
183
+
184
+ const result = attempt(() => ctx.deps.clearInterval(timer))
185
+ if (result instanceof Error) {
186
+ ctx.log.error({
187
+ action: "timer.clear.error",
188
+ message: safeErrorMessage({ error: result }),
189
+ error: result,
190
+ })
191
+ }
192
+ }
193
+
194
+ private runPoll(ctx: FlumeSourceStartContext): void {
195
+ safeInvokeCallback({
196
+ fn: async () => {
197
+ const result = await this.poll(ctx)
198
+ if (result instanceof Error) {
199
+ ctx.log.error({
200
+ action: "poll.error",
201
+ message: safeErrorMessage({ error: result }),
202
+ error: result,
203
+ })
204
+ }
205
+ },
206
+ onError: (error) => {
207
+ ctx.log.error({
208
+ action: "poll.unhandled",
209
+ message: safeErrorMessage({ error }),
210
+ error,
211
+ })
212
+ },
213
+ })
165
214
  }
166
215
 
167
- private async poll(ctx: FlumeSourceStartContext): Promise<void> {
168
- const res = await ctx.deps.fetch(this.options.url)
169
- const payload = await res.json()
216
+ private async poll(ctx: FlumeSourceStartContext): Promise<Error | null> {
217
+ const response = await attempt(() => ctx.deps.fetch(this.options.url))
218
+ if (response instanceof Error) return response
219
+ const raw = await safeReadText({ response, context: this.name })
220
+ if (raw instanceof Error) return raw
221
+ const payload = safeJsonParse(raw)
222
+ if (payload instanceof Error) return payload
223
+
170
224
  this.emit({
171
225
  source: "custom",
172
226
  sourceName: this.name,
173
227
  type: "webhook",
174
228
  data: { payload },
175
229
  meta: { event_type: "webhook" },
176
- receivedAt: ctx.deps.now(),
230
+ receivedAt: safeNow({ deps: ctx.deps }),
177
231
  })
232
+ return null
178
233
  }
179
234
  }
180
235
  ```
181
236
 
182
- `this.emit({...})` queues events through the base's serial queue and routes them to `ctx.onEvent` with `attempt()` isolation. `this.setStatus(...)` deduplicates idempotent transitions and logs the change (which is how status surfaces to consumers). Subclasses never need to write try/catch.
237
+ `this.emit({...})` queues events through the base's serial queue and routes them to `ctx.onEvent` with `attempt()` isolation. `this.setStatus(...)` deduplicates idempotent transitions and logs the change. IO started after `connect()` returns, such as timer callbacks, must use `attempt()` or `safeInvokeCallback()` as shown above; both are exported from the root entry so custom sources do not need raw try/catch.
183
238
 
184
239
  ## Time source
185
240
 
@@ -211,6 +266,8 @@ new FlumeTimeSource({
211
266
 
212
267
  `tick.firedAt` is the scheduled wall-clock time (epoch ms), not the exact `setTimeout` firing instant. A throwing `message()` is isolated and falls back to the defaults. Parse the cron up front with `parseCron(expr)` (returns `FlumeCron | FlumeParseError`) or compute the next fire with `flumeCronNext(cron, afterMs)`.
213
268
 
269
+ With `statePersister`, state writes are serialized in tick order and source shutdown waits for queued writes to settle. A failed write is logged and later writes still run. Keep the persister's IO bounded and avoid waiting for Flume lifecycle completion inside it. Stopping during `load()` releases startup immediately and ignores a late result; cancellation of the host's underlying read remains the persister's responsibility. Use `catchupPolicy` (`off`, `lastOnly`, or `missed`) to control replay on the next start. When multiple sources share a persistence key, the host must also serialize or atomically order their writes.
270
+
214
271
  ## Pull stream
215
272
 
216
273
  `FlumeRunning.stream()` is the pull form of the same firehose as `onEvent` — an async iterator over `FlumeStreamItem`, handy for feeding an agent (Claude / Codex) with `for await`, where backpressure falls out naturally from how fast you pull.
@@ -225,10 +282,10 @@ for await (const item of running.stream()) {
225
282
  if (item.kind === "event") await handleWithAgent(item.event)
226
283
  if (item.kind === "log" && item.log.action === "status") noticeDisconnect(item.log)
227
284
  }
228
- // loop ends when the flume stops (running.close() or signal abort)
285
+ // after close / signal abort, the loop ends once accepted callback diagnostics are delivered
229
286
  ```
230
287
 
231
- The iterator ends cleanly when the flume stops; `break`ing out unsubscribes automatically. When a slow consumer lets the buffer overflow, the oldest items are dropped by default:
288
+ The iterator ends cleanly after source shutdown and accepted callback work; `break`ing out unsubscribes automatically. When a slow consumer lets the buffer overflow, the oldest items are dropped by default:
232
289
 
233
290
  ```ts
234
291
  running.stream({ buffer: 5000, onOverflow: "drop-newest" })
@@ -284,7 +341,7 @@ type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent | Flume
284
341
 
285
342
  ## Observability
286
343
 
287
- Flume never calls a third-party service. Every internal action is reported through the firehose (`onEvent` push / `stream()` pull) as `{ kind: "log", log }` items — there are no silent paths. Each `FlumeLog` is tagged with `source: "flume"`, `source: "discord"`, `source: "slack"`, `source: "github"`, `source: "time"`, or your custom source's `name`. `onError` is a convenience filter that additionally receives only the `level: "error"` subset (route it straight to Sentry).
344
+ Flume never calls a third-party service. Every internal action is reported through the firehose (`onEvent` push / `stream()` pull) as `{ kind: "log", log }` items — there are no silent internal paths. Each `FlumeLog` is tagged with `source: "flume"`, `source: "discord"`, `source: "slack"`, `source: "github"`, `source: "time"`, or your custom source's `name`. `onError` is a convenience filter that additionally receives only the `level: "error"` subset (route it straight to Sentry).
288
345
 
289
346
  ```ts
290
347
  type FlumeLog = {
@@ -307,6 +364,9 @@ What gets logged:
307
364
  - Reconnect — `reconnect.scheduled` (with delay in ms), `reconnect.exhausted` (with attempt count), `reconnect.reset` (on successful connect after retries), `reconnect.cancel` (on stop).
308
365
  - Status transitions — `status` action with `previous → next`.
309
366
  - Errors — `level: "error"` carries the `error` field; use `onError` for a pre-filtered error sink.
367
+ - Observation sink failures — a failed `onEvent` emits `onEvent.error` to `onError` and the pull stream; a failed `onError` emits `onError.error` to `onEvent` and the pull stream. `detail` identifies the callback plus the failed item's kind, source, and event type or log action. `FlumeConfluence` also stamps `groupId` onto logs sent to its `onError`. The peer notification is attempted once, without recursive reporting.
368
+
369
+ For maximum diagnostics, configure both callbacks or keep a pull stream consumer. If both push callbacks fail, both failure logs remain available to the pull stream; startup logs are replayed to its first subscriber. `FlumeConfluence` has no pull iterator, so configure its `onError` as the independent diagnostic sink.
310
370
 
311
371
  Route it anywhere — Sentry, Datadog, `console`, a file — your choice. Filter the log items out of the firehose:
312
372
 
@@ -396,9 +456,9 @@ new Flume({
396
456
 
397
457
  ## Safety
398
458
 
399
- - Ordering — each source has its own `FlumeSerialQueue` and per-source events are delivered FIFO. Cross-source ordering between events from different sources is undefined (no global serialization). `onEvent` invocations are awaited and run one at a time per source, so async callbacks don't race and `close()` drains in-flight events before transitioning state.
459
+ - Ordering — each source has its own `FlumeSerialQueue` and per-source events are published FIFO. The public `onEvent` and `onError` callbacks share one global queue, so callback invocations never race across sources. `close()` stops the sources; a subsequent `drain()` waits for callbacks and their failure diagnostics. Pull subscribers receive items when they are published and have independent bounded buffers. The push callback queue is unbounded; use pull-only consumption for a bounded slow consumer.
400
460
  - Duplicate suppression — Slack envelopes are deduped by `envelope_id` (`FlumeSlackSeenCache`) to absorb ack retries. GitHub notifications are deduped by `id + updated_at` (`FlumeGitHubSeenCache`). Discord uses session resume so the Gateway does not re-emit dispatches.
401
- - Partial-failure rollback — if any source fails during `Flume.open()`, the already-started sources are stopped and a `FlumeStartError` is returned with per-source detail.
461
+ - Partial-failure rollback — if any source fails during `Flume.open()`, sources acquired by that open are stopped, including partially connected failures. A source that refused reuse remains with its existing owner. A `FlumeStartError` is returned with per-source detail.
402
462
  - Idempotent close — `FlumeRunning.close()` is safe to call concurrently; the first call wins and subsequent callers receive the same `FlumeClosed` snapshot. The same guard exists at source level: a double-`close()` (e.g. via signal abort racing a manual close) does not re-invoke `disconnect()`.
403
463
 
404
464
  ## Errors
@@ -410,9 +470,9 @@ Flume does not throw on protocol/network failures. Every entry point returns `T
410
470
  - `FlumeHttpError` — HTTP call returned an error payload (e.g. Slack `ok: false`)
411
471
  - `FlumeParseError` — Unparseable WebSocket frame
412
472
 
413
- Exceptions thrown from `onEvent` are caught and logged (never rethrown into the protocol loop).
473
+ Exceptions thrown or rejected from `onEvent` / `onError` are isolated and emitted as `onEvent.error` / `onError.error` diagnostics; they are never rethrown into the protocol loop.
414
474
 
415
- The library guarantees that no exception escapes any public surface constructors, `open()`, `close()`, the `onEvent` invocation path, the `stream()` iterator, the abort-signal path, and the `onError` callback all route IO and user-supplied callbacks through internal `safe*` wrappers and the generic `attempt()` helper. A misbehaving `onEvent` / `onError` will be logged and isolated rather than crashing the protocol loop.
475
+ With type-valid constructor input, the library guarantees that protocol IO and user callback failures do not escape `open()`, `close()`, the `onEvent` invocation path, the `stream()` iterator, the abort-signal path, or the `onError` callback. These boundaries route work through internal `safe*` wrappers and the generic `attempt()` helper.
416
476
 
417
477
  ## Supported sources
418
478
 
@@ -442,11 +502,11 @@ const github = new FlumeGitHubSource({ token })
442
502
 
443
503
  ```bash
444
504
  bun install
445
- bunx vp pack # build dist/
446
- bunx tsc --noEmit # typecheck
447
- bunx vitest run # tests
448
- bunx vp lint # lint
449
- bunx vp fmt # format
505
+ vp pack # build dist/
506
+ vp run typecheck # typecheck
507
+ vp test # tests
508
+ vp lint # lint
509
+ vp fmt # format
450
510
  ```
451
511
 
452
512
  The library itself is runtime-agnostic. The dev toolchain (build / test / lint) uses Bun + `vite-plus` + `vitest`, but the published `dist/` is plain ESM with TypeScript declarations and runs on Node 22+, Bun, Deno, Cloudflare Workers, or modern browsers. Runtimes without a global `WebSocket` (e.g. fetch-only edge functions) can still use `FlumeGitHubSource`; `FlumeDiscordSource` / `FlumeSlackSource` will return `FlumeStartError` at `open()` time if `deps.WebSocket` is `null`.
package/dist/discord.js CHANGED
@@ -1,8 +1,8 @@
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
- import { t as FlumeConnectionError } from "./connection-error.js";
3
- import { i as safeRandom, n as scheduleFlumeReconnect, r as FlumeReconnector, t as safeStringify } from "./safe-stringify.js";
1
+ import { a as safeInvokeCallback, 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 { n as safeRandom, r as FlumeConnectionError, t as safeStringify } from "./safe-stringify.js";
4
3
  import { t as isRecord } from "./is-record.js";
5
4
  import { t as safeJsonParse } from "./safe-json-parse.js";
5
+ import { n as FlumeReconnector, t as scheduleFlumeReconnect } from "./schedule-reconnect.js";
6
6
  import { z } from "zod/v4";
7
7
  //#region lib/discord/discord-gateway-session.ts
8
8
  var FlumeDiscordGatewaySession = class FlumeDiscordGatewaySession {
@@ -244,7 +244,9 @@ type FlumeTimeMessage = {
244
244
  /**
245
245
  * 起動 / 終了をまたいだ状態を 1 つ載せる純粋な DI ポート。flume 内部で fs / db / network を
246
246
  * 触らないように、I/O の場所と方式は host が決める。load の失敗は null 復帰扱い、save の
247
- * 失敗は best-effort (source 側で log するが throw しない)
247
+ * 失敗は best-effort (source 側で log するが throw しない)。Time source は save を直列実行し、
248
+ * stop 時に queued save の完了を待つ。persister 内から Flume の終了を await しない。
249
+ * stop は load の待機を解除するが、host 側で開始した read IO 自体は中断しない。
248
250
  */
249
251
  type FlumeStatePersister<S> = {
250
252
  load(): Promise<S | null>;
@@ -342,6 +344,8 @@ declare abstract class FlumeSource {
342
344
  * subclass が protocol 状態遷移をユーザーに通知する。同一 (status, detail) の連続は冪等
343
345
  */
344
346
  protected setStatus(status: FlumeStatus, detail?: string): void;
347
+ /** await をまたぐ接続処理が、停止後に新しいリソースを作らないための guard。 */
348
+ protected get isStopped(): boolean;
345
349
  /** subclass が現在の status を読みたい場合 */
346
350
  protected get currentStatus(): FlumeStatus;
347
351
  /** subclass が start ctx を再参照したい場合 (stop 後は null) */
@@ -82,6 +82,30 @@ var FlumeStartError = class extends Error {
82
82
  }
83
83
  };
84
84
  //#endregion
85
+ //#region lib/errors/source-reuse-error.ts
86
+ /** 起動を取得する前の拒否。Flume は他の起動処理が所有する Source を rollback しない。 */
87
+ var FlumeSourceReuseError = class extends FlumeStartError {};
88
+ //#endregion
89
+ //#region lib/utils/safe-invoke-callback.ts
90
+ /**
91
+ * fire-and-forget でユーザーコールバックを呼び出す。sync throw と async reject のどちらも
92
+ * `onError(Error)` に正規化して通知。`onError` 自身が throw しても外に漏らさない。
93
+ * 戻り値を持たない fire-and-forget 専用のため log/出力先には依存しない (caller が onError で決める)
94
+ */
95
+ function safeInvokeCallback(props) {
96
+ try {
97
+ Promise.resolve(props.fn()).catch((err) => {
98
+ try {
99
+ props.onError(safeNormalizeError({ value: err }));
100
+ } catch {}
101
+ }).catch(() => {});
102
+ } catch (err) {
103
+ try {
104
+ props.onError(safeNormalizeError({ value: err }));
105
+ } catch {}
106
+ }
107
+ }
108
+ //#endregion
85
109
  //#region lib/utils/safe-now.ts
86
110
  /**
87
111
  * `deps.now()` を保護する。throw / 非数値 / 非有限値が返った場合は `Date.now()` へ
@@ -145,64 +169,26 @@ var FlumeLogger = class FlumeLogger {
145
169
  error: input.error,
146
170
  detail: input.detail
147
171
  };
148
- try {
149
- Promise.resolve(handler(log)).catch(() => {});
150
- } catch {}
172
+ safeInvokeCallback({
173
+ fn: () => handler(log),
174
+ onError: () => {}
175
+ });
151
176
  }
152
177
  };
153
178
  //#endregion
154
- //#region lib/utils/safe-invoke-callback.ts
155
- /**
156
- * fire-and-forget でユーザーコールバックを呼び出す。sync throw と async reject のどちらも
157
- * `onError(Error)` に正規化して通知。`onError` 自身が throw しても外に漏らさない。
158
- * 戻り値を持たない fire-and-forget 専用のため log/出力先には依存しない (caller が onError で決める)
159
- */
160
- function safeInvokeCallback(props) {
161
- try {
162
- Promise.resolve(props.fn()).catch((err) => {
163
- try {
164
- props.onError(safeNormalizeError({ value: err }));
165
- } catch {}
166
- }).catch(() => {});
167
- } catch (err) {
168
- try {
169
- props.onError(safeNormalizeError({ value: err }));
170
- } catch {}
171
- }
172
- }
173
- //#endregion
174
179
  //#region lib/utils/serial-queue.ts
175
180
  /**
176
181
  * 投入順を保ったまま task を直列実行する。各 task は前の完了を待ってから走る。
177
182
  * task が throw しても後続には伝播しない (キュー自体は止まらない)。
178
- * maxDepth を超えた場合は新規 task を drop し onOverflow に通知。
179
- * cancel() 後は add() が no-op になり、既に積まれた未実行 task も実行せずに流れ落ちる。
180
183
  * drain() は待機中に追加された task も含めてキューが空になるまで待つ
181
184
  */
182
185
  var FlumeSerialQueue = class {
183
- props;
184
186
  chain = Promise.resolve();
185
- depth = 0;
186
- cancelled = false;
187
- constructor(props = {}) {
188
- this.props = props;
189
- }
190
187
  add(task) {
191
- if (this.cancelled) return Promise.resolve();
192
- if (this.props.maxDepth !== void 0 && this.depth >= this.props.maxDepth) {
193
- this.props.onOverflow?.({
194
- dropped: 1,
195
- depth: this.depth
196
- });
197
- return Promise.resolve();
198
- }
199
- this.depth++;
200
188
  const completion = this.chain.then(async () => {
201
189
  try {
202
- if (!this.cancelled) await task();
203
- } catch {} finally {
204
- this.depth--;
205
- }
190
+ await task();
191
+ } catch {}
206
192
  });
207
193
  this.chain = completion;
208
194
  return completion;
@@ -214,15 +200,6 @@ var FlumeSerialQueue = class {
214
200
  if (this.chain === current) return;
215
201
  }
216
202
  }
217
- cancel() {
218
- this.cancelled = true;
219
- }
220
- size() {
221
- return this.depth;
222
- }
223
- isCancelled() {
224
- return this.cancelled;
225
- }
226
203
  };
227
204
  //#endregion
228
205
  //#region lib/source-helpers/flume-status-emitter.ts
@@ -312,8 +289,8 @@ var FlumeSource = class {
312
289
  abortHandler = null;
313
290
  queue = new FlumeSerialQueue();
314
291
  async start(ctx) {
315
- if (this.consumed) return new FlumeStartError(`${this.name}: already started`);
316
- if (this.stopped) return new FlumeStartError(`${this.name}: already stopped`);
292
+ if (this.consumed) return new FlumeSourceReuseError("Source already started");
293
+ if (this.stopped) return new FlumeSourceReuseError("Source already stopped");
317
294
  this.consumed = true;
318
295
  this.ctx = ctx;
319
296
  this.statusEmitter = new FlumeStatusEmitter({
@@ -355,7 +332,7 @@ var FlumeSource = class {
355
332
  */
356
333
  emit(event) {
357
334
  const ctx = this.ctx;
358
- if (!ctx) return;
335
+ if (!ctx || this.stopped) return;
359
336
  this.queue.add(async () => {
360
337
  const result = await attempt(() => Promise.resolve(ctx.onEvent(event)));
361
338
  if (result instanceof Error) ctx.log.error({
@@ -369,8 +346,13 @@ var FlumeSource = class {
369
346
  * subclass が protocol 状態遷移をユーザーに通知する。同一 (status, detail) の連続は冪等
370
347
  */
371
348
  setStatus(status, detail) {
349
+ if (this.stopped) return;
372
350
  this.statusEmitter?.set(status, detail);
373
351
  }
352
+ /** await をまたぐ接続処理が、停止後に新しいリソースを作らないための guard。 */
353
+ get isStopped() {
354
+ return this.stopped;
355
+ }
374
356
  /** subclass が現在の status を読みたい場合 */
375
357
  get currentStatus() {
376
358
  return this.statusEmitter?.value ?? "disconnected";
@@ -414,4 +396,4 @@ var FlumeSource = class {
414
396
  }
415
397
  };
416
398
  //#endregion
417
- export { safeNow as a, attempt as c, FlumeLogger as i, safeNormalizeError as l, FlumeSerialQueue as n, FlumeStartError as o, safeInvokeCallback as r, FlumeParseError as s, FlumeSource as t, safeErrorMessage as u };
399
+ export { safeInvokeCallback as a, FlumeParseError as c, safeErrorMessage as d, safeNow as i, attempt as l, FlumeSerialQueue as n, FlumeSourceReuseError as o, FlumeLogger as r, FlumeStartError as s, FlumeSource as t, safeNormalizeError as u };
package/dist/github.js CHANGED
@@ -1,7 +1,6 @@
1
- import { a as safeNow, c as attempt, i as FlumeLogger, l as safeNormalizeError, s as FlumeParseError, t as FlumeSource, u as safeErrorMessage } from "./flume-source.js";
2
- import { t as FlumeHttpError } from "./http-error.js";
1
+ import { c as FlumeParseError, d as safeErrorMessage, i as safeNow, l as attempt, r as FlumeLogger, t as FlumeSource, u as safeNormalizeError } from "./flume-source.js";
2
+ import { n as FlumeHttpError, t as safeReadText } from "./safe-read-text.js";
3
3
  import { t as safeJsonParse } from "./safe-json-parse.js";
4
- import { t as safeReadText } from "./safe-read-text.js";
5
4
  import { z } from "zod/v4";
6
5
  //#region lib/github/extract-github-meta.ts
7
6
  function flumeExtractGitHubMeta(notification) {
@@ -161,10 +160,10 @@ var FlumeGitHubPoller = class {
161
160
  async poll() {
162
161
  if (this.inFlight || this.isStoppedFlag) return null;
163
162
  this.inFlight = true;
164
- try {
165
- return await this.pollOnce();
166
- } catch (err) {
167
- const cause = safeNormalizeError({ value: err });
163
+ const attempted = await attempt(async () => ({ result: await this.pollOnce() }));
164
+ this.inFlight = false;
165
+ if (attempted instanceof Error) {
166
+ const cause = safeNormalizeError({ value: attempted });
168
167
  const error = new FlumeHttpError({
169
168
  message: `poll loop threw: ${safeErrorMessage({ error: cause })}`,
170
169
  status: 0,
@@ -177,9 +176,8 @@ var FlumeGitHubPoller = class {
177
176
  });
178
177
  if (!this.bootstrapped) return error;
179
178
  return null;
180
- } finally {
181
- this.inFlight = false;
182
179
  }
180
+ return attempted.result;
183
181
  }
184
182
  async pollOnce() {
185
183
  const params = new URLSearchParams({