@interactive-inc/flume 0.6.0 → 0.9.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 CHANGED
@@ -4,7 +4,7 @@ Unified notification listener for Discord, Slack, and GitHub. Raw WebSocket + `f
4
4
 
5
5
  ```
6
6
  Discord ─┐
7
- Slack ─┼──▶ Flume ──start()──▶ FlumeEvent (one merged stream)
7
+ Slack ─┼──▶ Flume ──open()──▶ FlumeEvent (one merged stream)
8
8
  GitHub ─┘
9
9
  ```
10
10
 
@@ -24,8 +24,8 @@ import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
24
24
  import { FlumeSlackSource } from "@interactive-inc/flume/slack"
25
25
  import { FlumeGitHubSource } from "@interactive-inc/flume/github"
26
26
 
27
- const flume = new Flume(
28
- [
27
+ const flume = new Flume({
28
+ sources: [
29
29
  new FlumeDiscordSource({ token: process.env.DISCORD_BOT_TOKEN! }),
30
30
  new FlumeSlackSource({
31
31
  appToken: process.env.SLACK_APP_TOKEN!,
@@ -33,31 +33,31 @@ const flume = new Flume(
33
33
  }),
34
34
  new FlumeGitHubSource({ token: process.env.GITHUB_TOKEN!, pollInterval: 60 }),
35
35
  ],
36
- {
37
- onEvent: (event) => {
38
- console.log(event.source, event.type, event.meta)
39
- },
40
- onLog: (log) => console.log(`[${log.level}] ${log.source}/${log.action}: ${log.message}`),
41
- onStatus: (e) => console.log(`${e.source} → ${e.status}${e.detail ? ` (${e.detail})` : ""}`),
42
- reconnect: { maxAttempts: 10 },
36
+ onEvent: (item) => {
37
+ // single firehose: events + every log, discriminated by `kind`
38
+ if (item.kind === "event") console.log(item.event.source, item.event.type)
39
+ if (item.kind === "log") console.log(`[${item.log.level}] ${item.log.action}`)
43
40
  },
44
- )
41
+ onError: (log) => Sentry.captureException(log.error ?? new Error(log.message)),
42
+ reconnect: { maxAttempts: 10 },
43
+ })
44
+
45
+ const running = await flume.open()
45
46
 
46
- const running = await flume.start()
47
47
  if (running instanceof Error) throw running
48
48
 
49
49
  // later
50
- await running.stop()
50
+ await running.close()
51
51
  ```
52
52
 
53
- `new Flume(sources, options?)` — `sources` is the only required argument. `options` is an object and every field is optional: omit `onEvent` to drop events silently (connection-observation mode), omit `onLog` to disable logging, etc. All three callbacks share the `on*` naming for symmetry: `onEvent` is the business stream, `onLog` is the operational stream, `onStatus` is the connection-state stream.
53
+ `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).
54
54
 
55
55
  ```ts
56
56
  // Minimum: just open the protocols and discard everything.
57
- const flume = new Flume([new FlumeDiscordSource({ token })])
57
+ const flume = new Flume({ sources: [new FlumeDiscordSource({ token })] })
58
58
 
59
- // Connection-observation only: see when sources reconnect, ignore the payloads.
60
- const flume = new Flume([new FlumeDiscordSource({ token })], { onLog })
59
+ // Errors-only: forward failures to Sentry, ignore the rest.
60
+ const flume = new Flume({ sources: [new FlumeDiscordSource({ token })], onError })
61
61
  ```
62
62
 
63
63
  ## Lifecycle (type-state FSM)
@@ -65,32 +65,55 @@ const flume = new Flume([new FlumeDiscordSource({ token })], { onLog })
65
65
  `Flume` enforces lifecycle correctness through three classes — misuse becomes a compile error.
66
66
 
67
67
  ```
68
- Flume ──start()──▶ FlumeRunning ──stop()──▶ FlumeStopped
68
+ Flume ──open()──▶ FlumeRunning ──close()──▶ FlumeClosed
69
69
  (idle) (running) (terminal)
70
70
  ```
71
71
 
72
- - `Flume.start()` returns `FlumeRunning | FlumeStartError`. Branch with `instanceof Error`. On partial failure (one source fails while another succeeds), the already-started sources are rolled back and a `FlumeStartError` is returned with per-source detail in `.message`. Calling `start()` a second time on the same `Flume` instance returns `FlumeStartError` at runtime — the type system also rejects calling `start()` on the returned `FlumeRunning`/`FlumeStopped` handles.
73
- - `FlumeRunning.stop()` returns a `FlumeStopped` snapshot. `stop()` is idempotent and concurrent-safe.
74
- - `FlumeStopped` exposes only `statuses()` — a frozen snapshot of each source's final state. No `start`, no `stop`, no leaking source references.
75
- - An `AbortSignal` on `Flume` drives an automatic transition to `FlumeStopped`.
76
- - `FlumeRunning.kind === "running"` and `FlumeStopped.kind === "stopped"` provide a runtime discriminator when generic code holds the union.
72
+ - `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.
75
+ - An `AbortSignal` on `Flume` drives an automatic transition to `FlumeClosed`.
76
+ - `FlumeRunning.kind === "running"` and `FlumeClosed.kind === "closed"` provide a runtime discriminator when generic code holds the union.
77
77
 
78
78
  ```ts
79
- const running = await flume.start()
79
+ const running = await flume.open()
80
80
  if (running instanceof Error) {
81
81
  console.error(running.message)
82
- // "Flume.start: 1 source(s) failed: slack: connect refused"
82
+ // "Flume.open: 1 source(s) failed: slack: connect refused"
83
83
  return
84
84
  }
85
85
 
86
- running.start() // type error — `start` is not on FlumeRunning
86
+ running.open() // type error — `open` is not on FlumeRunning
87
87
 
88
- const stopped = await running.stop()
89
- stopped.stop() // type error
90
- stopped.start() // type error
91
- stopped.statuses() // [{ source: "discord", status: "disconnected" }, ...]
88
+ const closed = await running.close()
89
+ closed.close() // type error
90
+ closed.open() // type error
91
+ closed.statuses() // [{ source: "discord", status: "disconnected" }, ...]
92
92
  ```
93
93
 
94
+ ## Dynamic groups
95
+
96
+ 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.
97
+
98
+ ```ts
99
+ import { FlumeConfluence } from "@interactive-inc/flume"
100
+
101
+ const confluence = new FlumeConfluence({
102
+ onEvent: (item) => {
103
+ if (item.kind === "event") feedToAgent(item.event)
104
+ if (item.kind === "log" && item.log.action === "status") noticeDisconnect(item.log)
105
+ },
106
+ reconnect: { maxAttempts: 10 },
107
+ })
108
+
109
+ await confluence.add("team-a", [new FlumeDiscordSource({ token: tokenA })])
110
+ await confluence.add("team-b", [new FlumeSlackSource({ appToken, botToken })]) // existing groups keep running
111
+ await confluence.remove("team-a") // stops only team-a
112
+ await confluence.closeAll()
113
+ ```
114
+
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.
116
+
94
117
  ## Sub-entries
95
118
 
96
119
  Each source has a dedicated entry — importing one does not pull the others into your bundle. The root entry never loads source-specific code.
@@ -100,16 +123,18 @@ import { Flume, FlumeSource } from "@interactive-inc/flume"
100
123
  import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
101
124
  import { FlumeSlackSource } from "@interactive-inc/flume/slack"
102
125
  import { FlumeGitHubSource } from "@interactive-inc/flume/github"
126
+ import { FlumeTimeSource } from "@interactive-inc/flume/time"
103
127
  ```
104
128
 
105
- - `@interactive-inc/flume` — `Flume`, `FlumeRunning`, `FlumeStopped`, `FlumeSource` (abstract base for third-party sources), `createFlumeDefaultDeps`, errors, types
129
+ - `@interactive-inc/flume` — `Flume`, `FlumeConfluence`, `FlumeRunning`, `FlumeClosed`, `FlumeSource` (abstract base for third-party sources), `createFlumeDefaultDeps`, errors, types
106
130
  - `@interactive-inc/flume/discord` — `FlumeDiscordSource`, `FlumeDiscordGatewayIntents`, `flumeExtractDiscordMeta`
107
131
  - `@interactive-inc/flume/slack` — `FlumeSlackSource`, `flumeExtractSlackMeta`
108
132
  - `@interactive-inc/flume/github` — `FlumeGitHubSource`, `flumeExtractGitHubMeta`
133
+ - `@interactive-inc/flume/time` — `FlumeTimeSource`, `parseCron`, `flumeCronNext`
109
134
 
110
135
  ## Custom sources
111
136
 
112
- Extend `FlumeSource` to plug in any protocol. The base class owns `start`/`stop`/`status`, the per-source event queue, status emission with `onStatus` bridging, and consumed/stopped guards. You implement `connect` (open the protocol, emit events, set status) and `disconnect` (tear it down).
137
+ 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).
113
138
 
114
139
  ```ts
115
140
  import { FlumeSource } from "@interactive-inc/flume"
@@ -153,7 +178,62 @@ class MyWebhookSource extends FlumeSource {
153
178
  }
154
179
  ```
155
180
 
156
- `this.emit({...})` queues events through the base's serial queue and routes them to `ctx.onEvent` with `attempt()` isolation. `this.setStatus(...)` deduplicates idempotent transitions, logs the change, and forwards it to `ctx.onStatus`. Subclasses never need to write try/catch.
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.
182
+
183
+ ## Time source
184
+
185
+ `FlumeTimeSource` emits a `tick` event on a cron schedule. It holds no external connection, so it reaches `connected` as soon as it starts and is never a reconnect target. Useful as a heartbeat — a fixed marker in the same stream as your real sources.
186
+
187
+ ```ts
188
+ import { FlumeTimeSource } from "@interactive-inc/flume/time"
189
+
190
+ new Flume({
191
+ sources: [new FlumeTimeSource({ cron: "0 * * * *" })],
192
+ onEvent: (item) => console.log(item), // fires at minute 0 of every hour
193
+ })
194
+ ```
195
+
196
+ The cron expression is a standard 5-field spec (`minute hour day-of-month month day-of-week`) evaluated against the wall clock (local time). Supported per field: `*`, `*/n`, `a`, `a-b`, `a-b/n`, and comma lists. Day-of-week accepts `0-7` (`7` and `0` both mean Sunday). When both day-of-month and day-of-week are restricted, a day matches if either matches (standard cron semantics).
197
+
198
+ `message()` customizes each tick. Omitted fields fall back to the defaults (`type: "tick"`, `data: { firedAt, cron }`, `meta: { cron }`):
199
+
200
+ ```ts
201
+ new FlumeTimeSource({
202
+ cron: "*/15 * * * *",
203
+ message: (tick) => ({
204
+ type: "heartbeat",
205
+ data: { at: new Date(tick.firedAt).toISOString() },
206
+ meta: { channel: "ops" },
207
+ }),
208
+ })
209
+ ```
210
+
211
+ `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
+
213
+ ## Pull stream
214
+
215
+ `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.
216
+
217
+ ```ts
218
+ type FlumeStreamItem = { kind: "event"; event: FlumeEvent } | { kind: "log"; log: FlumeLog }
219
+
220
+ const running = await flume.open()
221
+ if (running instanceof Error) throw running
222
+
223
+ for await (const item of running.stream()) {
224
+ if (item.kind === "event") await handleWithAgent(item.event)
225
+ if (item.kind === "log" && item.log.action === "status") noticeDisconnect(item.log)
226
+ }
227
+ // loop ends when the flume stops (running.close() or signal abort)
228
+ ```
229
+
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:
231
+
232
+ ```ts
233
+ running.stream({ buffer: 5000, onOverflow: "drop-newest" })
234
+ ```
235
+
236
+ `stream()` (pull) and `onEvent` (push) carry the same items — use either or both. Multiple `stream()` consumers each get their own buffer.
157
237
 
158
238
  ## Event shape
159
239
 
@@ -181,8 +261,15 @@ type FlumeGitHubEvent = {
181
261
  meta: Record<string, string>
182
262
  receivedAt: number
183
263
  }
264
+ type FlumeTimeEvent = {
265
+ source: "time"
266
+ type: string // "tick" by default, or whatever your message() returns
267
+ data: Record<string, unknown>
268
+ meta: Record<string, string>
269
+ receivedAt: number
270
+ }
184
271
 
185
- type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent
272
+ type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent | FlumeTimeEvent
186
273
  ```
187
274
 
188
275
  `meta` is flat string keys tailored per source:
@@ -190,12 +277,13 @@ type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent
190
277
  - discord — `event_type`, `channel_id`, `guild_id`, `user_id`
191
278
  - slack — `event_type`, `channel_id`, `user_id`, `thread_ts`, `slack_event_type`
192
279
  - github — `event_type`, `reason`, `subject_type`, `repository`, `thread_id`
280
+ - time — `cron` by default, or whatever your `message()` returns
193
281
 
194
282
  `data` is the raw parsed payload (Zod-validated at the protocol boundary).
195
283
 
196
284
  ## Observability
197
285
 
198
- Flume never calls a third-party service. Every internal action is reported through the `onLog` callback you pass to `Flume` — there are no silent paths. Each `FlumeLog` is tagged with `source: "flume"`, `source: "discord"`, `source: "slack"`, `source: "github"`, or your custom source's `name`.
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).
199
287
 
200
288
  ```ts
201
289
  type FlumeLog = {
@@ -217,12 +305,14 @@ What gets logged:
217
305
  - Parse failures — any Zod schema mismatch emits a `warn` with the field paths and messages. Dropped GitHub notifications carry a per-item `parse.skip` and a `parse.summary` count. Slack envelopes that don't match `FlumeSlackEnvelopeSchema` emit `envelope.parse-fail` with the incoming `type` and the issues. Discord frames with an unknown op emit `ws.unknown-op`.
218
306
  - Reconnect — `reconnect.scheduled` (with delay in ms), `reconnect.exhausted` (with attempt count), `reconnect.reset` (on successful connect after retries), `reconnect.cancel` (on stop).
219
307
  - Status transitions — `status` action with `previous → next`.
220
- - Errors — `level: "error"` carries the `error` field so you can `captureException` in your `onLog` callback.
308
+ - Errors — `level: "error"` carries the `error` field; use `onError` for a pre-filtered error sink.
221
309
 
222
- Route it anywhere — Sentry, Datadog, `console`, a file — your choice:
310
+ Route it anywhere — Sentry, Datadog, `console`, a file — your choice. Filter the log items out of the firehose:
223
311
 
224
312
  ```ts
225
- onLog: (log) => {
313
+ onEvent: (item) => {
314
+ if (item.kind !== "log") return
315
+ const log = item.log
226
316
  if (log.level === "error" && log.error) {
227
317
  Sentry.captureException(log.error, { tags: { source: log.source, action: log.action } })
228
318
  }
@@ -237,7 +327,7 @@ onLog: (log) => {
237
327
 
238
328
  ```ts
239
329
  new Flume(sources, {
240
- onEvent: (event) => { ... },
330
+ onEvent: (item) => { ... },
241
331
  reconnect: {
242
332
  maxAttempts: 10,
243
333
  baseDelay: 1000, // first backoff
@@ -250,17 +340,17 @@ Exponential backoff with jitter. Discord resumes the session when possible — t
250
340
 
251
341
  ## Status
252
342
 
343
+ There is no dedicated status callback. Connection-state transitions ride the firehose as `status` log entries (`action: "status"`, message `previous → next`, with `detail.from` / `detail.to` / `detail.reason`). This is deliberate: a consumer feeding the firehose to an agent gets work (events) and drops (status logs) in one stream, no third callback.
344
+
253
345
  ```ts
254
- type FlumeStatusEvent = {
255
- source: string
256
- status: "disconnected" | "connecting" | "connected" | "reconnecting"
257
- detail?: string
346
+ onEvent: (item) => {
347
+ if (item.kind === "log" && item.log.action === "status") {
348
+ console.log(`${item.log.source}: ${item.log.message}`) // "github: connected reconnecting (HTTP 500)"
349
+ }
258
350
  }
259
-
260
- onStatus: (event: FlumeStatusEvent) => void
261
351
  ```
262
352
 
263
- GitHub populates `detail` with the failure reason (e.g. `"HTTP 500"`, `"network error"`). Discord and Slack leave `detail` undefined.
353
+ The live status of each source is also readable as a snapshot via `running.statuses()` and `closed.statuses()` (`{ source, status }[]`). GitHub populates the transition `reason` with the failure cause (e.g. `"HTTP 500"`, `"network error"`); Discord and Slack leave it null.
264
354
 
265
355
  ## Cancellation
266
356
 
@@ -269,18 +359,20 @@ Pass an `AbortSignal` to `Flume` — it propagates to every source via the auto-
269
359
  ```ts
270
360
  const controller = new AbortController()
271
361
 
272
- const flume = new Flume([new FlumeDiscordSource({ token })], {
273
- onEvent: (event) => { ... },
362
+ const flume = new Flume({
363
+ sources: [new FlumeDiscordSource({ token })],
364
+ onEvent: (item) => { ... },
274
365
  signal: controller.signal,
275
366
  })
276
367
 
277
- const running = await flume.start()
368
+ const running = await flume.open()
369
+
278
370
  if (running instanceof Error) throw running
279
371
 
280
- controller.abort() // FlumeRunning auto-transitions to FlumeStopped
372
+ controller.abort() // FlumeRunning auto-transitions to FlumeClosed
281
373
  ```
282
374
 
283
- If the signal is already aborted at `Flume.start()` time, `start` returns a `FlumeStartError` and no source is touched.
375
+ If the signal is already aborted at `Flume.open()` time, `open` returns a `FlumeStartError` and no source is touched.
284
376
 
285
377
  ## Dependency injection
286
378
 
@@ -289,8 +381,9 @@ Every IO boundary (`fetch`, `WebSocket`, `now`, `random`, timers) lives in `Flum
289
381
  ```ts
290
382
  import { createFlumeDefaultDeps } from "@interactive-inc/flume"
291
383
 
292
- new Flume([new FlumeDiscordSource({ token })], {
293
- onEvent: (event) => { ... },
384
+ new Flume({
385
+ sources: [new FlumeDiscordSource({ token })],
386
+ onEvent: (item) => { ... },
294
387
  deps: {
295
388
  ...createFlumeDefaultDeps(),
296
389
  fetch: mockFetch,
@@ -301,29 +394,30 @@ new Flume([new FlumeDiscordSource({ token })], {
301
394
 
302
395
  ## Safety
303
396
 
304
- - 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 `stop()` drains in-flight events before transitioning state.
397
+ - 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.
305
398
  - 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.
306
- - Partial-failure rollback — if any source fails during `Flume.start()`, the already-started sources are stopped and a `FlumeStartError` is returned with per-source detail.
307
- - Idempotent stop — `FlumeRunning.stop()` is safe to call concurrently; the first call wins and subsequent callers receive the same `FlumeStopped` snapshot. The same guard exists at source level: a double-`stop()` (e.g. via signal abort racing manual stop) does not re-invoke `disconnect()`.
399
+ - 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.
400
+ - 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()`.
308
401
 
309
402
  ## Errors
310
403
 
311
- Flume does not throw on protocol/network failures. Every entry point returns `T | Error` — branch with `instanceof Error`. `Flume.start()` returns `FlumeRunning | FlumeStartError`; protocol-layer helpers (`FlumeDiscordGateway.connect()`, etc.) return `T | Error`:
404
+ Flume does not throw on protocol/network failures. Every entry point returns `T | Error` — branch with `instanceof Error`. `Flume.open()` returns `FlumeRunning | FlumeStartError`; protocol-layer helpers (`FlumeDiscordGateway.connect()`, etc.) return `T | Error`:
312
405
 
313
- - `FlumeStartError` — `Flume.start()` refused or failed (already started, signal aborted, partial-failure rollback)
406
+ - `FlumeStartError` — `Flume.open()` refused or failed (already started, signal aborted, partial-failure rollback)
314
407
  - `FlumeConnectionError` — WebSocket closed before ready
315
408
  - `FlumeHttpError` — HTTP call returned an error payload (e.g. Slack `ok: false`)
316
409
  - `FlumeParseError` — Unparseable WebSocket frame
317
410
 
318
411
  Exceptions thrown from `onEvent` are caught and logged (never rethrown into the protocol loop).
319
412
 
320
- The library guarantees that no exception escapes any public surface — constructors, `start()`, `stop()`, the `onEvent` invocation path, the abort-signal path, and the `onLog` / `onStatus` callbacks all route IO and user-supplied callbacks through internal `safe*` wrappers and the generic `attempt()` helper. A misbehaving `onEvent` / `onLog` / `onStatus` will be logged and isolated rather than crashing the protocol loop.
413
+ 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.
321
414
 
322
415
  ## Supported sources
323
416
 
324
417
  - Discord — Gateway WebSocket v10 (JSON), bot token
325
418
  - Slack — Socket Mode WebSocket, app token + bot token (both required)
326
419
  - GitHub — REST polling `/notifications`, personal access token
420
+ - Time — cron-scheduled ticks, no external connection
327
421
 
328
422
  GitHub also exposes `gh auth token` if you want to reuse the `gh` CLI's session:
329
423
 
@@ -335,9 +429,9 @@ const github = new FlumeGitHubSource({ token })
335
429
 
336
430
  ## Module layout
337
431
 
338
- - `Flume` / `FlumeRunning` / `FlumeStopped` — type-state FSM merging multiple sources into one stream
432
+ - `Flume` / `FlumeRunning` / `FlumeClosed` — type-state FSM merging multiple sources into one stream
339
433
  - `FlumeSource` — abstract base class for any protocol source; extend it to plug in your own
340
- - `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` — built-in sources, each extending `FlumeSource`
434
+ - `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` / `FlumeTimeSource` — built-in sources, each extending `FlumeSource`
341
435
  - `FlumeRuntimeDeps` — IO boundary port (`fetch`, `WebSocket`, `now`, `random`, timers); `WebSocket` is nullable for fetch-only runtimes
342
436
  - `flumeExtractDiscordMeta` / `flumeExtractSlackMeta` / `flumeExtractGitHubMeta` — pure functions that build `FlumeEvent.meta` from each protocol's payload shape
343
437
  - Internal protocol modules (`FlumeDiscordGateway`, `FlumeSlackSocketMode`, `FlumeGitHubPoller`, `FlumeReconnector`, `FlumeLogger`, seen caches, schemas) are not part of the public surface — depend on the high-level Sources only
@@ -353,7 +447,7 @@ bunx vp lint # lint
353
447
  bunx vp fmt # format
354
448
  ```
355
449
 
356
- 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 `start()` time if `deps.WebSocket` is `null`.
450
+ 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`.
357
451
 
358
452
  ## License
359
453
 
package/dist/discord.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as FlumeSourceStartContext, r as FlumeDiscordSourceOptions, t as FlumeSource } from "./flume-source-DuUFPhSe.js";
1
+ import { r as FlumeDiscordSourceOptions, t as FlumeSource, w as FlumeSourceStartContext } from "./flume-source.js";
2
2
 
3
3
  //#region lib/discord/discord-source.d.ts
4
4
  declare class FlumeDiscordSource extends FlumeSource {
package/dist/discord.js CHANGED
@@ -1,7 +1,8 @@
1
- import { a as FlumeStartError, c as safeNormalizeError, i as safeNow, l as safeErrorMessage, n as safeInvokeCallback, o as FlumeParseError, r as FlumeLogger, s as attempt, t as FlumeSource } from "./flume-source-DUvt9aJt.js";
2
- import { t as FlumeConnectionError } from "./connection-error-HUO3PC3G.js";
3
- import { a as safeRandom, i as FlumeReconnector, n as isRecord, r as scheduleFlumeReconnect, t as safeStringify } from "./safe-stringify-DbWQw9qe.js";
4
- import { t as safeJsonParse } from "./safe-json-parse-CfJjt-RY.js";
1
+ import { a as FlumeStartError, c as safeNormalizeError, i as safeNow, l as safeErrorMessage, n as safeInvokeCallback, o as FlumeParseError, r as FlumeLogger, s as attempt, t as FlumeSource } 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";
4
+ import { t as isRecord } from "./is-record.js";
5
+ import { t as safeJsonParse } from "./safe-json-parse.js";
5
6
  import { z } from "zod/v4";
6
7
  //#region lib/discord/discord-gateway-session.ts
7
8
  var FlumeDiscordGatewaySession = class FlumeDiscordGatewaySession {
@@ -74,7 +74,7 @@ type FlumeRuntimeDeps = {
74
74
  setInterval(fn: () => void, ms: number): FlumeTimerHandle;
75
75
  clearInterval(id: FlumeTimerHandle): void;
76
76
  };
77
- type FlumeSourceName = "discord" | "slack" | "github";
77
+ type FlumeSourceName = "discord" | "slack" | "github" | "time";
78
78
  type FlumeDiscordEvent = {
79
79
  source: "discord";
80
80
  type: string;
@@ -96,15 +96,29 @@ type FlumeGitHubEvent = {
96
96
  meta: Record<string, string>;
97
97
  receivedAt: number;
98
98
  };
99
- type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent;
99
+ type FlumeTimeEvent = {
100
+ source: "time";
101
+ type: string;
102
+ data: Record<string, unknown>;
103
+ meta: Record<string, string>;
104
+ receivedAt: number;
105
+ };
106
+ type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent | FlumeTimeEvent;
100
107
  type FlumeEventHandler = (event: FlumeEvent) => void | Promise<void>;
101
- type FlumeStatus = "disconnected" | "connecting" | "connected" | "reconnecting";
102
- type FlumeStatusEvent = {
103
- source: string;
104
- status: FlumeStatus;
105
- detail?: string;
108
+ type FlumeStreamItem = {
109
+ kind: "event";
110
+ event: FlumeEvent;
111
+ } | {
112
+ kind: "log";
113
+ log: FlumeLog;
114
+ };
115
+ type FlumeStreamHandler = (item: FlumeStreamItem) => void;
116
+ type FlumeStreamOverflow = "drop-oldest" | "drop-newest";
117
+ type FlumeStreamOptions = {
118
+ /** バッファ上限 (既定 1000)。consumer が遅れて溢れたら onOverflow に従う */buffer?: number; /** バッファ溢れ時の方針 (既定 "drop-oldest") */
119
+ onOverflow?: FlumeStreamOverflow;
106
120
  };
107
- type FlumeStatusHandler = (event: FlumeStatusEvent) => void;
121
+ type FlumeStatus = "disconnected" | "connecting" | "connected" | "reconnecting";
108
122
  type FlumeSourceStatus = {
109
123
  /**
110
124
  * 多くは `FlumeSourceName` のいずれかだが、`source.name` getter が throw する
@@ -124,6 +138,8 @@ type FlumeLog = {
124
138
  timestamp: number;
125
139
  };
126
140
  type FlumeLogHandler = (log: FlumeLog) => void;
141
+ /** error レベルのログだけを受け取る (Sentry など error 専用の送信先向け) */
142
+ type FlumeErrorHandler = (log: FlumeLog) => void;
127
143
  type FlumeLogInput = {
128
144
  action: string;
129
145
  message: string;
@@ -144,9 +160,17 @@ type FlumeSourceLocalStatusHandler = (status: FlumeStatus, detail?: string) => v
144
160
  type FlumeSourceStartContext = {
145
161
  onEvent: FlumeEventHandler;
146
162
  log: FlumeLogger;
147
- deps: FlumeRuntimeDeps;
148
- onStatus: FlumeSourceLocalStatusHandler;
163
+ deps: FlumeRuntimeDeps; /** Source 内部の status 遷移ブリッジ。Flume 公開 API に status callback は無く、遷移は log に出る */
164
+ onStatus?: FlumeSourceLocalStatusHandler;
149
165
  reconnect: FlumeReconnectConfig | null;
166
+ /**
167
+ * Flume.start() に渡された signal をそのまま転送する。
168
+ * source 実装が自前で `fetch(url, { signal })` / `setTimeout` cancel / WS close を
169
+ * host abort 経由で発火させたい時に使う (Flume 自身は最外殻で runClose を駆動するので
170
+ * source は signal を無視しても動作的には停止する — 自然な伝播パスが欲しい場合のみ)。
171
+ * Flume.options.signal が未設定なら省略される。
172
+ */
173
+ signal?: AbortSignal;
150
174
  };
151
175
  type FlumeDiscordSourceOptions = {
152
176
  token: string;
@@ -164,6 +188,23 @@ type FlumeGitHubSourceOptions = {
164
188
  token: string;
165
189
  pollInterval?: number;
166
190
  };
191
+ type FlumeTimeTick = {
192
+ /** cron がマッチした壁時計時刻 (epoch ms)。setTimeout の発火実時刻ではなく予定時刻 */firedAt: number;
193
+ cron: string;
194
+ };
195
+ /**
196
+ * tick ごとに emit するイベントの上書き内容。全フィールド optional。
197
+ * 省略フィールドは既定値 (type: "tick" / data: tick 内容 / meta: { cron }) になる
198
+ */
199
+ type FlumeTimeMessage = {
200
+ type?: string;
201
+ data?: Record<string, unknown>;
202
+ meta?: Record<string, string>;
203
+ };
204
+ type FlumeTimeSourceOptions = {
205
+ /** 5 フィールド cron 式 (minute hour day-of-month month day-of-week)。壁時計 (local time) 基準 */cron: string;
206
+ message?: (tick: FlumeTimeTick) => FlumeTimeMessage;
207
+ };
167
208
  type FlumeGatewayMessage = z.infer<typeof FlumeGatewayMessageSchema>;
168
209
  type FlumeSlackEnvelope = z.infer<typeof FlumeSlackEnvelopeSchema>;
169
210
  type FlumeSlackConnectionResponse = z.infer<typeof FlumeSlackConnectionResponseSchema>;
@@ -225,4 +266,4 @@ declare abstract class FlumeSource {
225
266
  protected abstract disconnect(): Promise<void> | void;
226
267
  }
227
268
  //#endregion
228
- export { FlumeSourceStartContext as C, FlumeStatusHandler as D, FlumeStatusEvent as E, FlumeTimerHandle as O, FlumeSourceName as S, FlumeStatus as T, FlumeSlackConnectionResponse as _, FlumeEventHandler as a, FlumeSlackSourceOptions as b, FlumeGitHubNotification as c, FlumeLogHandler as d, FlumeLogInput as f, FlumeRuntimeDeps as g, FlumeReconnectOptions as h, FlumeEvent as i, FlumeLogger as k, FlumeGitHubSourceOptions as l, FlumeReconnectConfig as m, FlumeDiscordEvent as n, FlumeGatewayMessage as o, FlumeLogLevel as p, FlumeDiscordSourceOptions as r, FlumeGitHubEvent as s, FlumeSource as t, FlumeLog as u, FlumeSlackEnvelope as v, FlumeSourceStatus as w, FlumeSourceLocalStatusHandler as x, FlumeSlackEvent as y };
269
+ export { FlumeStreamOverflow as A, FlumeSourceName as C, FlumeStreamHandler as D, FlumeStatus as E, FlumeTimerHandle as F, FlumeLogger as I, FlumeTimeMessage as M, FlumeTimeSourceOptions as N, FlumeStreamItem as O, FlumeTimeTick as P, FlumeSourceLocalStatusHandler as S, FlumeSourceStatus as T, FlumeRuntimeDeps as _, FlumeEvent as a, FlumeSlackEvent as b, FlumeGitHubEvent as c, FlumeLog as d, FlumeLogHandler as f, FlumeReconnectOptions as g, FlumeReconnectConfig as h, FlumeErrorHandler as i, FlumeTimeEvent as j, FlumeStreamOptions as k, FlumeGitHubNotification as l, FlumeLogLevel as m, FlumeDiscordEvent as n, FlumeEventHandler as o, FlumeLogInput as p, FlumeDiscordSourceOptions as r, FlumeGatewayMessage as s, FlumeSource as t, FlumeGitHubSourceOptions as u, FlumeSlackConnectionResponse as v, FlumeSourceStartContext as w, FlumeSlackSourceOptions as x, FlumeSlackEnvelope as y };
@@ -298,10 +298,13 @@ var FlumeSource = class {
298
298
  async stop() {
299
299
  if (this.stopped) return;
300
300
  this.stopped = true;
301
- await this.disconnect();
302
- await this.queue.drain();
303
- this.statusEmitter?.set("disconnected");
304
- this.ctx = null;
301
+ try {
302
+ await this.disconnect();
303
+ } finally {
304
+ await this.queue.drain();
305
+ this.statusEmitter?.set("disconnected");
306
+ this.ctx = null;
307
+ }
305
308
  }
306
309
  status() {
307
310
  return this.statusEmitter?.value ?? "disconnected";
package/dist/github.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as FlumeSourceStartContext, c as FlumeGitHubNotification, l as FlumeGitHubSourceOptions, t as FlumeSource } from "./flume-source-DuUFPhSe.js";
1
+ import { l as FlumeGitHubNotification, t as FlumeSource, u as FlumeGitHubSourceOptions, w as FlumeSourceStartContext } from "./flume-source.js";
2
2
 
3
3
  //#region lib/github/github-source.d.ts
4
4
  declare class FlumeGitHubSource extends FlumeSource {
package/dist/github.js CHANGED
@@ -1,7 +1,7 @@
1
- import { c as safeNormalizeError, i as safeNow, l as safeErrorMessage, o as FlumeParseError, r as FlumeLogger, s as attempt, t as FlumeSource } from "./flume-source-DUvt9aJt.js";
2
- import { t as FlumeHttpError } from "./http-error-CPSKoSie.js";
3
- import { t as safeJsonParse } from "./safe-json-parse-CfJjt-RY.js";
4
- import { t as safeReadText } from "./safe-read-text-JQd_5vbd.js";
1
+ import { c as safeNormalizeError, i as safeNow, l as safeErrorMessage, o as FlumeParseError, r as FlumeLogger, s as attempt, t as FlumeSource } from "./flume-source.js";
2
+ import { t as FlumeHttpError } from "./http-error.js";
3
+ import { t as safeJsonParse } from "./safe-json-parse.js";
4
+ import { t as safeReadText } from "./safe-read-text.js";
5
5
  import { z } from "zod/v4";
6
6
  //#region lib/github/extract-github-meta.ts
7
7
  function flumeExtractGitHubMeta(notification) {
@@ -43,8 +43,12 @@ var FlumeGitHubSeenCache = class {
43
43
  }
44
44
  trim() {
45
45
  if (this.seen.size <= this.props.maxSize) return;
46
- const entries = [...this.seen.entries()];
47
- this.seen = new Map(entries.slice(entries.length - this.props.maxSize));
46
+ let removeCount = this.seen.size - this.props.maxSize;
47
+ for (const id of this.seen.keys()) {
48
+ if (removeCount <= 0) break;
49
+ this.seen.delete(id);
50
+ removeCount--;
51
+ }
48
52
  }
49
53
  get size() {
50
54
  return this.seen.size;
@@ -104,6 +108,7 @@ var FlumeGitHubPoller = class {
104
108
  const error = await this.poll();
105
109
  if (error) return error;
106
110
  if (this.isStoppedFlag) return null;
111
+ if (this.rateLimitTimer !== null) return null;
107
112
  this.scheduleInterval();
108
113
  return null;
109
114
  }
@@ -128,7 +133,7 @@ var FlumeGitHubPoller = class {
128
133
  message: safeErrorMessage({ error }),
129
134
  error
130
135
  });
131
- }).catch(() => {});
136
+ });
132
137
  }, this.effectiveIntervalSec * 1e3));
133
138
  if (intervalResult instanceof Error) {
134
139
  this.log.error({