@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 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,33 +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
- source: this.name as "discord", // declare your own discriminant via FlumeEvent extension
225
+ source: "custom",
226
+ sourceName: this.name,
172
227
  type: "webhook",
173
- data: payload,
228
+ data: { payload },
174
229
  meta: { event_type: "webhook" },
175
- receivedAt: ctx.deps.now(),
230
+ receivedAt: safeNow({ deps: ctx.deps }),
176
231
  })
232
+ return null
177
233
  }
178
234
  }
179
235
  ```
180
236
 
181
- `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.
182
238
 
183
239
  ## Time source
184
240
 
@@ -210,6 +266,8 @@ new FlumeTimeSource({
210
266
 
211
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)`.
212
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
+
213
271
  ## Pull stream
214
272
 
215
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.
@@ -224,10 +282,10 @@ for await (const item of running.stream()) {
224
282
  if (item.kind === "event") await handleWithAgent(item.event)
225
283
  if (item.kind === "log" && item.log.action === "status") noticeDisconnect(item.log)
226
284
  }
227
- // 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
228
286
  ```
229
287
 
230
- 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:
231
289
 
232
290
  ```ts
233
291
  running.stream({ buffer: 5000, onOverflow: "drop-newest" })
@@ -283,7 +341,7 @@ type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent | Flume
283
341
 
284
342
  ## Observability
285
343
 
286
- 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).
287
345
 
288
346
  ```ts
289
347
  type FlumeLog = {
@@ -306,6 +364,9 @@ What gets logged:
306
364
  - Reconnect — `reconnect.scheduled` (with delay in ms), `reconnect.exhausted` (with attempt count), `reconnect.reset` (on successful connect after retries), `reconnect.cancel` (on stop).
307
365
  - Status transitions — `status` action with `previous → next`.
308
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.
309
370
 
310
371
  Route it anywhere — Sentry, Datadog, `console`, a file — your choice. Filter the log items out of the firehose:
311
372
 
@@ -395,9 +456,9 @@ new Flume({
395
456
 
396
457
  ## Safety
397
458
 
398
- - 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.
399
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.
400
- - 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.
401
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()`.
402
463
 
403
464
  ## Errors
@@ -409,9 +470,9 @@ Flume does not throw on protocol/network failures. Every entry point returns `T
409
470
  - `FlumeHttpError` — HTTP call returned an error payload (e.g. Slack `ok: false`)
410
471
  - `FlumeParseError` — Unparseable WebSocket frame
411
472
 
412
- 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.
413
474
 
414
- 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.
415
476
 
416
477
  ## Supported sources
417
478
 
@@ -441,11 +502,11 @@ const github = new FlumeGitHubSource({ token })
441
502
 
442
503
  ```bash
443
504
  bun install
444
- bunx vp pack # build dist/
445
- bunx tsc --noEmit # typecheck
446
- bunx vitest run # tests
447
- bunx vp lint # lint
448
- 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
449
510
  ```
450
511
 
451
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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { D as FlumeSourceStartContext, o as FlumeDiscordSourceOptions, t as FlumeSource } from "./flume-source.js";
1
+ import { O as FlumeSourceStartContext, s as FlumeDiscordSourceOptions, t as FlumeSource } from "./flume-source.js";
2
2
 
3
3
  //#region lib/discord/discord-source.d.ts
4
4
  declare class FlumeDiscordSource extends FlumeSource {
@@ -10,10 +10,24 @@ declare class FlumeDiscordSource extends FlumeSource {
10
10
  protected connect(ctx: FlumeSourceStartContext): Promise<Error | null>;
11
11
  protected disconnect(): void;
12
12
  private hasWebSocket;
13
+ /**
14
+ * gateway を 1 接続 = 1 インスタンスで作り直す。`session` は前回接続から引き継いだ
15
+ * resume 可能な session (無ければ IDENTIFY)。await 後は `this.gateway` でなく local な
16
+ * `gateway` を参照する (並行する close() が `this.gateway` を null 化しても壊れない)
17
+ */
13
18
  private connectInternal;
14
19
  private dispatch;
15
20
  private safeExtractMeta;
21
+ /**
22
+ * status は発火元 gateway に束縛して受ける。交換済み (stale) な gateway からの通知は無視し、
23
+ * 現行 gateway の状態を誤って上書きしない
24
+ */
16
25
  private handleGatewayStatus;
26
+ /**
27
+ * resume 可能な session はこの時点で捕捉して次の gateway へ引き継ぐ (gateway インスタンスは
28
+ * 接続ごとに破棄されるため)。resume できない = IDENTIFY し直す再接続には identify rate limit
29
+ * (1 回 / 5 秒) を守る下限 delay を敷く
30
+ */
17
31
  private scheduleReconnect;
18
32
  }
19
33
  //#endregion