@interactive-inc/flume 0.4.0 → 0.9.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
@@ -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(handler)──▶ FlumeEvent (one merged stream)
7
+ Slack ─┼──▶ Flume ──open()──▶ FlumeEvent (one merged stream)
8
8
  GitHub ─┘
9
9
  ```
10
10
 
@@ -24,29 +24,40 @@ 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 onLog = (log) => console.log(`[${log.level}] ${log.source}/${log.action}: ${log.message}`)
28
-
29
27
  const flume = new Flume({
30
28
  sources: [
31
- new FlumeDiscordSource({ token: process.env.DISCORD_BOT_TOKEN!, onLog, reconnect: true }),
29
+ new FlumeDiscordSource({ token: process.env.DISCORD_BOT_TOKEN! }),
32
30
  new FlumeSlackSource({
33
31
  appToken: process.env.SLACK_APP_TOKEN!,
34
32
  botToken: process.env.SLACK_BOT_TOKEN!,
35
- onLog,
36
- reconnect: true,
37
33
  }),
38
- new FlumeGitHubSource({ token: process.env.GITHUB_TOKEN!, onLog, pollInterval: 60 }),
34
+ new FlumeGitHubSource({ token: process.env.GITHUB_TOKEN!, pollInterval: 60 }),
39
35
  ],
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}`)
40
+ },
41
+ onError: (log) => Sentry.captureException(log.error ?? new Error(log.message)),
42
+ reconnect: { maxAttempts: 10 },
40
43
  })
41
44
 
42
- const running = await flume.start((event) => {
43
- console.log(event.source, event.type, event.meta)
44
- })
45
+ const running = await flume.open()
45
46
 
46
47
  if (running instanceof Error) throw running
47
48
 
48
49
  // later
49
- await running.stop()
50
+ await running.close()
51
+ ```
52
+
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
+
55
+ ```ts
56
+ // Minimum: just open the protocols and discard everything.
57
+ const flume = new Flume({ sources: [new FlumeDiscordSource({ token })] })
58
+
59
+ // Errors-only: forward failures to Sentry, ignore the rest.
60
+ const flume = new Flume({ sources: [new FlumeDiscordSource({ token })], onError })
50
61
  ```
51
62
 
52
63
  ## Lifecycle (type-state FSM)
@@ -54,69 +65,176 @@ await running.stop()
54
65
  `Flume` enforces lifecycle correctness through three classes — misuse becomes a compile error.
55
66
 
56
67
  ```
57
- Flume ──start()──▶ FlumeRunning ──stop()──▶ FlumeStopped
68
+ Flume ──open()──▶ FlumeRunning ──close()──▶ FlumeClosed
58
69
  (idle) (running) (terminal)
59
70
  ```
60
71
 
61
- - `Flume.start(handler)` 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.
62
- - `FlumeRunning.stop()` returns a `FlumeStopped` snapshot. `stop()` is idempotent and concurrent-safe.
63
- - `FlumeStopped` exposes only `statuses()` — a frozen snapshot of each source's final state. No `start`, no `stop`, no leaking source references.
64
- - An `AbortSignal` on `Flume` drives an automatic transition to `FlumeStopped`.
65
- - `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.
66
77
 
67
78
  ```ts
68
- const running = await flume.start(handler)
79
+ const running = await flume.open()
69
80
  if (running instanceof Error) {
70
81
  console.error(running.message)
71
- // "Flume.start: 1 source(s) failed: slack: connect refused"
82
+ // "Flume.open: 1 source(s) failed: slack: connect refused"
72
83
  return
73
84
  }
74
85
 
75
- running.start() // type error — `start` is not on FlumeRunning
86
+ running.open() // type error — `open` is not on FlumeRunning
76
87
 
77
- const stopped = await running.stop()
78
- stopped.stop() // type error
79
- stopped.start() // type error
80
- 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" }, ...]
81
92
  ```
82
93
 
83
- ## Direct source usage
94
+ ## Dynamic groups
84
95
 
85
- Sources work standalone — `Flume` is only needed for multi-source orchestration.
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.
86
97
 
87
98
  ```ts
88
- import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
99
+ import { FlumeConfluence } from "@interactive-inc/flume"
89
100
 
90
- const source = new FlumeDiscordSource({
91
- token: process.env.DISCORD_BOT_TOKEN!,
92
- reconnect: true,
93
- onLog: (log) => console.log(log),
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 },
94
107
  })
95
108
 
96
- const error = await source.start((event) => {
97
- /* ... */
98
- })
99
- if (error instanceof Error) throw error
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()
100
113
  ```
101
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
+
102
117
  ## Sub-entries
103
118
 
104
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.
105
120
 
106
- | sub-entry | exports |
107
- | -------------------------------- | -------------------------------------------------------------------------------- |
108
- | `@interactive-inc/flume` | `Flume`, `FlumeRunning`, `FlumeStopped`, `createFlumeDefaultDeps`, errors, types |
109
- | `@interactive-inc/flume/discord` | `FlumeDiscordSource`, `FlumeDiscordGatewayIntents`, `flumeExtractDiscordMeta` |
110
- | `@interactive-inc/flume/slack` | `FlumeSlackSource`, `flumeExtractSlackMeta` |
111
- | `@interactive-inc/flume/github` | `FlumeGitHubSource`, `flumeExtractGitHubMeta` |
112
-
113
121
  ```ts
114
- import { Flume } from "@interactive-inc/flume"
122
+ import { Flume, FlumeSource } from "@interactive-inc/flume"
115
123
  import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
116
124
  import { FlumeSlackSource } from "@interactive-inc/flume/slack"
117
125
  import { FlumeGitHubSource } from "@interactive-inc/flume/github"
126
+ import { FlumeTimeSource } from "@interactive-inc/flume/time"
127
+ ```
128
+
129
+ - `@interactive-inc/flume` — `Flume`, `FlumeConfluence`, `FlumeRunning`, `FlumeClosed`, `FlumeSource` (abstract base for third-party sources), `createFlumeDefaultDeps`, errors, types
130
+ - `@interactive-inc/flume/discord` — `FlumeDiscordSource`, `FlumeDiscordGatewayIntents`, `flumeExtractDiscordMeta`
131
+ - `@interactive-inc/flume/slack` — `FlumeSlackSource`, `flumeExtractSlackMeta`
132
+ - `@interactive-inc/flume/github` — `FlumeGitHubSource`, `flumeExtractGitHubMeta`
133
+ - `@interactive-inc/flume/time` — `FlumeTimeSource`, `parseCron`, `flumeCronNext`
134
+
135
+ ## Custom sources
136
+
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).
138
+
139
+ ```ts
140
+ import { FlumeSource } from "@interactive-inc/flume"
141
+ import type { FlumeSourceStartContext } from "@interactive-inc/flume"
142
+
143
+ class MyWebhookSource extends FlumeSource {
144
+ readonly name = "my-webhook"
145
+
146
+ private timer: ReturnType<typeof setInterval> | null = null
147
+
148
+ constructor(private readonly options: { url: string; pollInterval?: number }) {
149
+ super()
150
+ }
151
+
152
+ protected async connect(ctx: FlumeSourceStartContext): Promise<Error | null> {
153
+ this.setStatus("connecting")
154
+ const interval = (this.options.pollInterval ?? 30) * 1000
155
+ this.timer = ctx.deps.setInterval(() => this.poll(ctx), interval) as ReturnType<
156
+ typeof setInterval
157
+ >
158
+ this.setStatus("connected")
159
+ return null
160
+ }
161
+
162
+ protected disconnect(): void {
163
+ if (this.timer) clearInterval(this.timer)
164
+ this.timer = null
165
+ }
166
+
167
+ private async poll(ctx: FlumeSourceStartContext): Promise<void> {
168
+ const res = await ctx.deps.fetch(this.options.url)
169
+ const payload = await res.json()
170
+ this.emit({
171
+ source: this.name as "discord", // declare your own discriminant via FlumeEvent extension
172
+ type: "webhook",
173
+ data: payload,
174
+ meta: { event_type: "webhook" },
175
+ receivedAt: ctx.deps.now(),
176
+ })
177
+ }
178
+ }
179
+ ```
180
+
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
+ })
118
209
  ```
119
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.
237
+
120
238
  ## Event shape
121
239
 
122
240
  Every source emits the same `FlumeEvent` — a discriminated union keyed on `source` so `data` narrows automatically:
@@ -143,23 +261,29 @@ type FlumeGitHubEvent = {
143
261
  meta: Record<string, string>
144
262
  receivedAt: number
145
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
+ }
146
271
 
147
- type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent
272
+ type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent | FlumeTimeEvent
148
273
  ```
149
274
 
150
275
  `meta` is flat string keys tailored per source:
151
276
 
152
- | source | meta keys |
153
- | ------- | ---------------------------------------------------------------------- |
154
- | discord | `event_type`, `channel_id`, `guild_id`, `user_id` |
155
- | slack | `event_type`, `channel_id`, `user_id`, `thread_ts`, `slack_event_type` |
156
- | github | `event_type`, `reason`, `subject_type`, `repository`, `thread_id` |
277
+ - discord `event_type`, `channel_id`, `guild_id`, `user_id`
278
+ - slack `event_type`, `channel_id`, `user_id`, `thread_ts`, `slack_event_type`
279
+ - github `event_type`, `reason`, `subject_type`, `repository`, `thread_id`
280
+ - time `cron` by default, or whatever your `message()` returns
157
281
 
158
282
  `data` is the raw parsed payload (Zod-validated at the protocol boundary).
159
283
 
160
284
  ## Observability
161
285
 
162
- Flume never calls a third-party service. Every internal action is reported through the `onLog` callback you pass to `Flume` or each source — there are no silent paths.
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).
163
287
 
164
288
  ```ts
165
289
  type FlumeLog = {
@@ -175,18 +299,20 @@ type FlumeLog = {
175
299
 
176
300
  What gets logged:
177
301
 
178
- - **HTTP boundary** — every request URL, response status, and parsed body shape (`http.request` / `http.response` / `http.body`). Slack's `apps.connections.open` call and GitHub's poll request both emit these.
179
- - **WebSocket boundary** — every inbound frame (`ws.recv`), every outbound frame (`ws.send` / `ws.sent`), with a 200-byte preview and total byte count. Discord op / t / s is decoded into structured `detail`.
180
- - **Protocol lifecycle** — Discord HELLO / READY / RESUMED / RECONNECT / INVALID_SESSION / HEARTBEAT / HEARTBEAT_ACK, Slack hello / disconnect / envelope ack, GitHub bootstrap / fresh / idle.
181
- - **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`.
182
- - **Reconnect** — `reconnect.scheduled` (with delay in ms), `reconnect.exhausted` (with attempt count), `reconnect.reset` (on successful connect after retries), `reconnect.cancel` (on stop).
183
- - **Status transitions** — `status` action with `previous → next`.
184
- - **Errors** — `level: "error"` carries the `error` field so you can `captureException` in your handler.
302
+ - HTTP boundary — every request URL, response status, and parsed body shape (`http.request` / `http.response` / `http.body`). Slack's `apps.connections.open` call and GitHub's poll request both emit these.
303
+ - WebSocket boundary — every inbound frame (`ws.recv`), every outbound frame (`ws.send` / `ws.sent`), with a 200-byte preview and total byte count. Discord op / t / s is decoded into structured `detail`.
304
+ - Protocol lifecycle — Discord HELLO / READY / RESUMED / RECONNECT / INVALID_SESSION / HEARTBEAT / HEARTBEAT_ACK, Slack hello / disconnect / envelope ack, GitHub bootstrap / fresh / idle.
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`.
306
+ - Reconnect — `reconnect.scheduled` (with delay in ms), `reconnect.exhausted` (with attempt count), `reconnect.reset` (on successful connect after retries), `reconnect.cancel` (on stop).
307
+ - Status transitions — `status` action with `previous → next`.
308
+ - Errors — `level: "error"` carries the `error` field; use `onError` for a pre-filtered error sink.
185
309
 
186
- 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:
187
311
 
188
312
  ```ts
189
- onLog: (log) => {
313
+ onEvent: (item) => {
314
+ if (item.kind !== "log") return
315
+ const log = item.log
190
316
  if (log.level === "error" && log.error) {
191
317
  Sentry.captureException(log.error, { tags: { source: log.source, action: log.action } })
192
318
  }
@@ -197,56 +323,67 @@ onLog: (log) => {
197
323
 
198
324
  ## Reconnect
199
325
 
200
- `reconnect` accepts `true`, an options object, or is omitted (no reconnect).
326
+ `reconnect` on `Flume` is an options object omit it to disable reconnects entirely.
201
327
 
202
328
  ```ts
203
- reconnect: {
204
- maxAttempts: 10,
205
- baseDelay: 1000, // first backoff
206
- maxDelay: 30000, // backoff cap
207
- }
329
+ new Flume(sources, {
330
+ onEvent: (item) => { ... },
331
+ reconnect: {
332
+ maxAttempts: 10,
333
+ baseDelay: 1000, // first backoff
334
+ maxDelay: 30000, // backoff cap
335
+ },
336
+ })
208
337
  ```
209
338
 
210
- Exponential backoff with jitter. Discord resumes the session when possible — the session id and resume URL are carried across reconnects via `FlumeDiscordGatewaySession`.
339
+ Exponential backoff with jitter. Discord resumes the session when possible — the session id and resume URL are carried across reconnects via `FlumeDiscordGatewaySession`. GitHub polling doesn't need reconnect (it's stateless polling); the option is wired through but ignored.
211
340
 
212
341
  ## Status
213
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
+
214
345
  ```ts
215
- onStatus: (status: "disconnected" | "connecting" | "connected" | "reconnecting", detail?: string) => void
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
+ }
350
+ }
216
351
  ```
217
352
 
218
- 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.
219
354
 
220
355
  ## Cancellation
221
356
 
222
- Pass an `AbortSignal` to `Flume` (propagates to every source) or to an individual source.
357
+ Pass an `AbortSignal` to `Flume` — it propagates to every source via the auto-stop pathway.
223
358
 
224
- ````ts
359
+ ```ts
225
360
  const controller = new AbortController()
226
361
 
227
362
  const flume = new Flume({
228
- sources: [new FlumeDiscordSource({ token, signal: controller.signal })],
363
+ sources: [new FlumeDiscordSource({ token })],
364
+ onEvent: (item) => { ... },
229
365
  signal: controller.signal,
230
366
  })
231
367
 
232
- ```ts
233
- const running = await flume.start(handler)
368
+ const running = await flume.open()
369
+
234
370
  if (running instanceof Error) throw running
235
371
 
236
- controller.abort() // FlumeRunning auto-transitions to FlumeStopped
237
- ````
372
+ controller.abort() // FlumeRunning auto-transitions to FlumeClosed
373
+ ```
238
374
 
239
- 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.
240
376
 
241
377
  ## Dependency injection
242
378
 
243
- Every IO boundary (`fetch`, `WebSocket`, `now`, `random`, timers) lives in `FlumeRuntimeDeps`. `deps` is optional on every source — when omitted, `createFlumeDefaultDeps()` wraps the global equivalents. Override only when you need mocks or runtime-specific shims.
379
+ Every IO boundary (`fetch`, `WebSocket`, `now`, `random`, timers) lives in `FlumeRuntimeDeps`. `deps` is optional on `Flume` — when omitted, `createFlumeDefaultDeps()` wraps the global equivalents. Override only when you need mocks or runtime-specific shims. The same `deps` is handed to every source through its start context.
244
380
 
245
381
  ```ts
246
382
  import { createFlumeDefaultDeps } from "@interactive-inc/flume"
247
383
 
248
- new FlumeDiscordSource({
249
- token,
384
+ new Flume({
385
+ sources: [new FlumeDiscordSource({ token })],
386
+ onEvent: (item) => { ... },
250
387
  deps: {
251
388
  ...createFlumeDefaultDeps(),
252
389
  fetch: mockFetch,
@@ -257,31 +394,30 @@ new FlumeDiscordSource({
257
394
 
258
395
  ## Safety
259
396
 
260
- - **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). Handler invocations are awaited and run one at a time per source, so async handlers don't race and `stop()` drains in-flight events before transitioning state.
261
- - **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.
262
- - **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.
263
- - **Idempotent stop** — `FlumeRunning.stop()` is safe to call concurrently; the first call wins and subsequent callers receive the same `FlumeStopped` snapshot.
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.
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.
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()`.
264
401
 
265
402
  ## Errors
266
403
 
267
- Flume does not throw on protocol/network failures. Every entry point returns `T | Error` — branch with `instanceof Error`. `Flume.start()` returns `FlumeRunning | FlumeStartError`; `Source.start()` returns `Error | null`; protocol-layer helpers (`FlumeDiscordGateway.connect()`, `obtainSlackUrl()`, …) 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`:
268
405
 
269
- - `FlumeStartError` — `Flume.start()` / `Source.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)
270
407
  - `FlumeConnectionError` — WebSocket closed before ready
271
408
  - `FlumeHttpError` — HTTP call returned an error payload (e.g. Slack `ok: false`)
272
409
  - `FlumeParseError` — Unparseable WebSocket frame
273
410
 
274
- Internal handler exceptions are caught and logged (never rethrown into the protocol loop).
411
+ Exceptions thrown from `onEvent` are caught and logged (never rethrown into the protocol loop).
275
412
 
276
- The library guarantees that no exception escapes any public surface — constructors, `start()`, `stop()`, the handler invocation path, the abort-signal path, and the `onLog` / `onStatus` callbacks all route IO and user-supplied callbacks through internal `safe*` wrappers (`safeNow`, `safeRandom`, `safeReadText`, `safeNewWebSocket`, `safeWsSend`, `safeWsClose`, `safeInvokeCallback`, `safeAddAbortListener`, `safeRemoveAbortListener`, `safeSourceStatus`). A misbehaving `onStatus` / `onLog` / `handler` 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.
277
414
 
278
415
  ## Supported sources
279
416
 
280
- | source | transport | auth |
281
- | ------- | ----------------------------- | ------------------------------------- |
282
- | Discord | Gateway WebSocket v10 (JSON) | bot token |
283
- | Slack | Socket Mode WebSocket | app token + bot token (both required) |
284
- | GitHub | REST polling `/notifications` | personal access token |
417
+ - Discord Gateway WebSocket v10 (JSON), bot token
418
+ - Slack Socket Mode WebSocket, app token + bot token (both required)
419
+ - GitHub REST polling `/notifications`, personal access token
420
+ - Time cron-scheduled ticks, no external connection
285
421
 
286
422
  GitHub also exposes `gh auth token` if you want to reuse the `gh` CLI's session:
287
423
 
@@ -293,8 +429,9 @@ const github = new FlumeGitHubSource({ token })
293
429
 
294
430
  ## Module layout
295
431
 
296
- - `Flume` / `FlumeRunning` / `FlumeStopped` — type-state FSM merging multiple sources into one stream
297
- - `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` high-level sources (each conforms to the structural `FlumeSource` type)
432
+ - `Flume` / `FlumeRunning` / `FlumeClosed` — type-state FSM merging multiple sources into one stream
433
+ - `FlumeSource` abstract base class for any protocol source; extend it to plug in your own
434
+ - `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` / `FlumeTimeSource` — built-in sources, each extending `FlumeSource`
298
435
  - `FlumeRuntimeDeps` — IO boundary port (`fetch`, `WebSocket`, `now`, `random`, timers); `WebSocket` is nullable for fetch-only runtimes
299
436
  - `flumeExtractDiscordMeta` / `flumeExtractSlackMeta` / `flumeExtractGitHubMeta` — pure functions that build `FlumeEvent.meta` from each protocol's payload shape
300
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
@@ -310,7 +447,7 @@ bunx vp lint # lint
310
447
  bunx vp fmt # format
311
448
  ```
312
449
 
313
- 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`.
314
451
 
315
452
  ## License
316
453
 
package/dist/discord.d.ts CHANGED
@@ -1,25 +1,17 @@
1
- import { C as FlumeSourceStartOptions, T as FlumeStatus, c as FlumeHandler, n as FlumeDiscordSourceOptions } from "./types-D-tO-Mh2.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
- declare class FlumeDiscordSource {
4
+ declare class FlumeDiscordSource extends FlumeSource {
5
5
  private readonly options;
6
6
  readonly name: "discord";
7
7
  private gateway;
8
8
  private reconnector;
9
- private handler;
10
- private readonly log;
11
- private readonly deps;
12
- private readonly queue;
13
- private readonly signals;
14
- private readonly statusEmitter;
15
- private readonly onSignalAbort;
16
9
  constructor(options: FlumeDiscordSourceOptions);
17
- start(handler: FlumeHandler, options?: FlumeSourceStartOptions): Promise<Error | null>;
18
- stop(): Promise<void>;
19
- status(): FlumeStatus;
10
+ protected connect(ctx: FlumeSourceStartContext): Promise<Error | null>;
11
+ protected disconnect(): void;
20
12
  private hasWebSocket;
21
13
  private connectInternal;
22
- private handleDispatch;
14
+ private dispatch;
23
15
  private safeExtractMeta;
24
16
  private handleGatewayStatus;
25
17
  private scheduleReconnect;