@interactive-inc/flume 0.4.0 → 0.6.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 ──start()──▶ FlumeEvent (one merged stream)
8
8
  GitHub ─┘
9
9
  ```
10
10
 
@@ -24,31 +24,42 @@ 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
- const flume = new Flume({
30
- sources: [
31
- new FlumeDiscordSource({ token: process.env.DISCORD_BOT_TOKEN!, onLog, reconnect: true }),
27
+ const flume = new Flume(
28
+ [
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
  ],
40
- })
41
-
42
- const running = await flume.start((event) => {
43
- console.log(event.source, event.type, event.meta)
44
- })
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 },
43
+ },
44
+ )
45
45
 
46
+ const running = await flume.start()
46
47
  if (running instanceof Error) throw running
47
48
 
48
49
  // later
49
50
  await running.stop()
50
51
  ```
51
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.
54
+
55
+ ```ts
56
+ // Minimum: just open the protocols and discard everything.
57
+ const flume = new Flume([new FlumeDiscordSource({ token })])
58
+
59
+ // Connection-observation only: see when sources reconnect, ignore the payloads.
60
+ const flume = new Flume([new FlumeDiscordSource({ token })], { onLog })
61
+ ```
62
+
52
63
  ## Lifecycle (type-state FSM)
53
64
 
54
65
  `Flume` enforces lifecycle correctness through three classes — misuse becomes a compile error.
@@ -58,14 +69,14 @@ Flume ──start()──▶ FlumeRunning ──stop()──▶ FlumeStopped
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.
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.
62
73
  - `FlumeRunning.stop()` returns a `FlumeStopped` snapshot. `stop()` is idempotent and concurrent-safe.
63
74
  - `FlumeStopped` exposes only `statuses()` — a frozen snapshot of each source's final state. No `start`, no `stop`, no leaking source references.
64
75
  - An `AbortSignal` on `Flume` drives an automatic transition to `FlumeStopped`.
65
76
  - `FlumeRunning.kind === "running"` and `FlumeStopped.kind === "stopped"` 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.start()
69
80
  if (running instanceof Error) {
70
81
  console.error(running.message)
71
82
  // "Flume.start: 1 source(s) failed: slack: connect refused"
@@ -80,43 +91,70 @@ stopped.start() // type error
80
91
  stopped.statuses() // [{ source: "discord", status: "disconnected" }, ...]
81
92
  ```
82
93
 
83
- ## Direct source usage
94
+ ## Sub-entries
84
95
 
85
- Sources work standalone`Flume` is only needed for multi-source orchestration.
96
+ Each source has a dedicated entry importing one does not pull the others into your bundle. The root entry never loads source-specific code.
86
97
 
87
98
  ```ts
99
+ import { Flume, FlumeSource } from "@interactive-inc/flume"
88
100
  import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
89
-
90
- const source = new FlumeDiscordSource({
91
- token: process.env.DISCORD_BOT_TOKEN!,
92
- reconnect: true,
93
- onLog: (log) => console.log(log),
94
- })
95
-
96
- const error = await source.start((event) => {
97
- /* ... */
98
- })
99
- if (error instanceof Error) throw error
101
+ import { FlumeSlackSource } from "@interactive-inc/flume/slack"
102
+ import { FlumeGitHubSource } from "@interactive-inc/flume/github"
100
103
  ```
101
104
 
102
- ## Sub-entries
105
+ - `@interactive-inc/flume` — `Flume`, `FlumeRunning`, `FlumeStopped`, `FlumeSource` (abstract base for third-party sources), `createFlumeDefaultDeps`, errors, types
106
+ - `@interactive-inc/flume/discord` — `FlumeDiscordSource`, `FlumeDiscordGatewayIntents`, `flumeExtractDiscordMeta`
107
+ - `@interactive-inc/flume/slack` — `FlumeSlackSource`, `flumeExtractSlackMeta`
108
+ - `@interactive-inc/flume/github` — `FlumeGitHubSource`, `flumeExtractGitHubMeta`
103
109
 
104
- Each source has a dedicated entry — importing one does not pull the others into your bundle. The root entry never loads source-specific code.
110
+ ## Custom sources
105
111
 
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
+ 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).
112
113
 
113
114
  ```ts
114
- import { Flume } from "@interactive-inc/flume"
115
- import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
116
- import { FlumeSlackSource } from "@interactive-inc/flume/slack"
117
- import { FlumeGitHubSource } from "@interactive-inc/flume/github"
115
+ import { FlumeSource } from "@interactive-inc/flume"
116
+ import type { FlumeSourceStartContext } from "@interactive-inc/flume"
117
+
118
+ class MyWebhookSource extends FlumeSource {
119
+ readonly name = "my-webhook"
120
+
121
+ private timer: ReturnType<typeof setInterval> | null = null
122
+
123
+ constructor(private readonly options: { url: string; pollInterval?: number }) {
124
+ super()
125
+ }
126
+
127
+ protected async connect(ctx: FlumeSourceStartContext): Promise<Error | null> {
128
+ this.setStatus("connecting")
129
+ const interval = (this.options.pollInterval ?? 30) * 1000
130
+ this.timer = ctx.deps.setInterval(() => this.poll(ctx), interval) as ReturnType<
131
+ typeof setInterval
132
+ >
133
+ this.setStatus("connected")
134
+ return null
135
+ }
136
+
137
+ protected disconnect(): void {
138
+ if (this.timer) clearInterval(this.timer)
139
+ this.timer = null
140
+ }
141
+
142
+ private async poll(ctx: FlumeSourceStartContext): Promise<void> {
143
+ const res = await ctx.deps.fetch(this.options.url)
144
+ const payload = await res.json()
145
+ this.emit({
146
+ source: this.name as "discord", // declare your own discriminant via FlumeEvent extension
147
+ type: "webhook",
148
+ data: payload,
149
+ meta: { event_type: "webhook" },
150
+ receivedAt: ctx.deps.now(),
151
+ })
152
+ }
153
+ }
118
154
  ```
119
155
 
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.
157
+
120
158
  ## Event shape
121
159
 
122
160
  Every source emits the same `FlumeEvent` — a discriminated union keyed on `source` so `data` narrows automatically:
@@ -149,17 +187,15 @@ type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent
149
187
 
150
188
  `meta` is flat string keys tailored per source:
151
189
 
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` |
190
+ - discord `event_type`, `channel_id`, `guild_id`, `user_id`
191
+ - slack `event_type`, `channel_id`, `user_id`, `thread_ts`, `slack_event_type`
192
+ - github `event_type`, `reason`, `subject_type`, `repository`, `thread_id`
157
193
 
158
194
  `data` is the raw parsed payload (Zod-validated at the protocol boundary).
159
195
 
160
196
  ## Observability
161
197
 
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.
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`.
163
199
 
164
200
  ```ts
165
201
  type FlumeLog = {
@@ -175,13 +211,13 @@ type FlumeLog = {
175
211
 
176
212
  What gets logged:
177
213
 
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.
214
+ - 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.
215
+ - 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`.
216
+ - Protocol lifecycle — Discord HELLO / READY / RESUMED / RECONNECT / INVALID_SESSION / HEARTBEAT / HEARTBEAT_ACK, Slack hello / disconnect / envelope ack, GitHub bootstrap / fresh / idle.
217
+ - 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
+ - Reconnect — `reconnect.scheduled` (with delay in ms), `reconnect.exhausted` (with attempt count), `reconnect.reset` (on successful connect after retries), `reconnect.cancel` (on stop).
219
+ - Status transitions — `status` action with `previous → next`.
220
+ - Errors — `level: "error"` carries the `error` field so you can `captureException` in your `onLog` callback.
185
221
 
186
222
  Route it anywhere — Sentry, Datadog, `console`, a file — your choice:
187
223
 
@@ -197,56 +233,64 @@ onLog: (log) => {
197
233
 
198
234
  ## Reconnect
199
235
 
200
- `reconnect` accepts `true`, an options object, or is omitted (no reconnect).
236
+ `reconnect` on `Flume` is an options object omit it to disable reconnects entirely.
201
237
 
202
238
  ```ts
203
- reconnect: {
204
- maxAttempts: 10,
205
- baseDelay: 1000, // first backoff
206
- maxDelay: 30000, // backoff cap
207
- }
239
+ new Flume(sources, {
240
+ onEvent: (event) => { ... },
241
+ reconnect: {
242
+ maxAttempts: 10,
243
+ baseDelay: 1000, // first backoff
244
+ maxDelay: 30000, // backoff cap
245
+ },
246
+ })
208
247
  ```
209
248
 
210
- Exponential backoff with jitter. Discord resumes the session when possible — the session id and resume URL are carried across reconnects via `FlumeDiscordGatewaySession`.
249
+ 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
250
 
212
251
  ## Status
213
252
 
214
253
  ```ts
215
- onStatus: (status: "disconnected" | "connecting" | "connected" | "reconnecting", detail?: string) => void
254
+ type FlumeStatusEvent = {
255
+ source: string
256
+ status: "disconnected" | "connecting" | "connected" | "reconnecting"
257
+ detail?: string
258
+ }
259
+
260
+ onStatus: (event: FlumeStatusEvent) => void
216
261
  ```
217
262
 
218
263
  GitHub populates `detail` with the failure reason (e.g. `"HTTP 500"`, `"network error"`). Discord and Slack leave `detail` undefined.
219
264
 
220
265
  ## Cancellation
221
266
 
222
- Pass an `AbortSignal` to `Flume` (propagates to every source) or to an individual source.
267
+ Pass an `AbortSignal` to `Flume` — it propagates to every source via the auto-stop pathway.
223
268
 
224
- ````ts
269
+ ```ts
225
270
  const controller = new AbortController()
226
271
 
227
- const flume = new Flume({
228
- sources: [new FlumeDiscordSource({ token, signal: controller.signal })],
272
+ const flume = new Flume([new FlumeDiscordSource({ token })], {
273
+ onEvent: (event) => { ... },
229
274
  signal: controller.signal,
230
275
  })
231
276
 
232
- ```ts
233
- const running = await flume.start(handler)
277
+ const running = await flume.start()
234
278
  if (running instanceof Error) throw running
235
279
 
236
280
  controller.abort() // FlumeRunning auto-transitions to FlumeStopped
237
- ````
281
+ ```
238
282
 
239
283
  If the signal is already aborted at `Flume.start()` time, `start` returns a `FlumeStartError` and no source is touched.
240
284
 
241
285
  ## Dependency injection
242
286
 
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.
287
+ 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
288
 
245
289
  ```ts
246
290
  import { createFlumeDefaultDeps } from "@interactive-inc/flume"
247
291
 
248
- new FlumeDiscordSource({
249
- token,
292
+ new Flume([new FlumeDiscordSource({ token })], {
293
+ onEvent: (event) => { ... },
250
294
  deps: {
251
295
  ...createFlumeDefaultDeps(),
252
296
  fetch: mockFetch,
@@ -257,31 +301,29 @@ new FlumeDiscordSource({
257
301
 
258
302
  ## Safety
259
303
 
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.
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.
305
+ - 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()`.
264
308
 
265
309
  ## Errors
266
310
 
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`:
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`:
268
312
 
269
- - `FlumeStartError` — `Flume.start()` / `Source.start()` refused or failed (already started, signal aborted, partial-failure rollback)
313
+ - `FlumeStartError` — `Flume.start()` refused or failed (already started, signal aborted, partial-failure rollback)
270
314
  - `FlumeConnectionError` — WebSocket closed before ready
271
315
  - `FlumeHttpError` — HTTP call returned an error payload (e.g. Slack `ok: false`)
272
316
  - `FlumeParseError` — Unparseable WebSocket frame
273
317
 
274
- Internal handler exceptions are caught and logged (never rethrown into the protocol loop).
318
+ Exceptions thrown from `onEvent` are caught and logged (never rethrown into the protocol loop).
275
319
 
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.
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.
277
321
 
278
322
  ## Supported sources
279
323
 
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 |
324
+ - Discord Gateway WebSocket v10 (JSON), bot token
325
+ - Slack Socket Mode WebSocket, app token + bot token (both required)
326
+ - GitHub REST polling `/notifications`, personal access token
285
327
 
286
328
  GitHub also exposes `gh auth token` if you want to reuse the `gh` CLI's session:
287
329
 
@@ -294,7 +336,8 @@ const github = new FlumeGitHubSource({ token })
294
336
  ## Module layout
295
337
 
296
338
  - `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)
339
+ - `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`
298
341
  - `FlumeRuntimeDeps` — IO boundary port (`fetch`, `WebSocket`, `now`, `random`, timers); `WebSocket` is nullable for fetch-only runtimes
299
342
  - `flumeExtractDiscordMeta` / `flumeExtractSlackMeta` / `flumeExtractGitHubMeta` — pure functions that build `FlumeEvent.meta` from each protocol's payload shape
300
343
  - 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
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 { C as FlumeSourceStartContext, r as FlumeDiscordSourceOptions, t as FlumeSource } from "./flume-source-DuUFPhSe.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;
package/dist/discord.js CHANGED
@@ -1,7 +1,7 @@
1
- import { a as FlumeParseError, c as safeNormalizeError, i as FlumeStartError, l as safeErrorMessage, n as FlumeLogger, o as createFlumeDefaultDeps, r as safeNow, s as attempt, t as safeInvokeCallback } from "./safe-invoke-callback-EpWXwfwp.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-DUvt9aJt.js";
2
2
  import { t as FlumeConnectionError } from "./connection-error-HUO3PC3G.js";
3
- import { a as FlumeReconnector, i as resolveFlumeReconnectConfig, n as isRecord, o as safeRandom, r as scheduleFlumeReconnect, t as safeStringify } from "./safe-stringify-BWS-uXZP.js";
4
- import { i as safeJsonParse, n as FlumeStatusEmitter, r as FlumeSignalRegistry, t as FlumeSerialQueue } from "./serial-queue-B9LoBc64.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";
5
5
  import { z } from "zod/v4";
6
6
  //#region lib/discord/discord-gateway-session.ts
7
7
  var FlumeDiscordGatewaySession = class FlumeDiscordGatewaySession {
@@ -647,89 +647,38 @@ function flumeExtractDiscordMeta(eventName, eventData) {
647
647
  //#endregion
648
648
  //#region lib/discord/discord-source.ts
649
649
  const DEFAULT_INTENTS = FlumeDiscordGatewayIntents.Guilds | FlumeDiscordGatewayIntents.GuildMessages | FlumeDiscordGatewayIntents.DirectMessages;
650
- var FlumeDiscordSource = class {
650
+ var FlumeDiscordSource = class extends FlumeSource {
651
651
  options;
652
652
  name = "discord";
653
653
  gateway = null;
654
654
  reconnector = null;
655
- handler = null;
656
- log;
657
- deps;
658
- queue = new FlumeSerialQueue();
659
- signals;
660
- statusEmitter;
661
- onSignalAbort = () => {
662
- safeInvokeCallback({
663
- fn: () => this.stop(),
664
- onError: (error) => {
665
- this.log.error({
666
- action: "signal.abort.stop.failed",
667
- message: safeErrorMessage({ error }),
668
- error
669
- });
670
- }
671
- });
672
- };
673
655
  constructor(options) {
656
+ super();
674
657
  this.options = options;
675
- this.deps = options.deps ?? createFlumeDefaultDeps();
676
- this.log = new FlumeLogger({
677
- source: "discord",
678
- handler: options.onLog,
679
- deps: this.deps
680
- });
681
- this.signals = new FlumeSignalRegistry({
682
- log: this.log,
683
- onAbort: this.onSignalAbort
684
- });
685
- this.statusEmitter = new FlumeStatusEmitter({
686
- log: this.log,
687
- onStatus: options.onStatus
688
- });
689
- const rc = resolveFlumeReconnectConfig(options.reconnect);
690
- if (rc) this.reconnector = new FlumeReconnector({
691
- ...rc,
692
- log: this.log,
693
- deps: this.deps
694
- });
695
658
  }
696
- async start(handler, options) {
697
- if (this.signals.isAnyAborted(this.options.signal) || this.signals.isAnyAborted(options?.signal)) return new FlumeStartError("Discord source: signal already aborted");
698
- if (!this.hasWebSocket()) return new FlumeStartError("Discord source: deps.WebSocket is null (no WebSocket runtime available)");
699
- this.signals.register(this.options.signal);
700
- this.signals.register(options?.signal);
701
- this.handler = handler;
702
- this.log.info({
703
- action: "source.start",
704
- message: "starting Discord source"
659
+ async connect(ctx) {
660
+ if (!this.hasWebSocket(ctx)) return new FlumeStartError("Discord source: deps.WebSocket is null (no WebSocket runtime available)");
661
+ if (ctx.reconnect && !this.reconnector) this.reconnector = new FlumeReconnector({
662
+ ...ctx.reconnect,
663
+ log: ctx.log,
664
+ deps: ctx.deps
705
665
  });
706
- return await this.connectInternal();
666
+ return await this.connectInternal(ctx);
707
667
  }
708
- async stop() {
709
- this.signals.unregisterAll();
710
- this.log.info({
711
- action: "source.stop",
712
- message: "stopping Discord source"
713
- });
714
- if (this.reconnector && !this.reconnector.aborted) this.log.debug({
668
+ disconnect() {
669
+ if (this.reconnector && !this.reconnector.aborted) this.context?.log.debug({
715
670
  action: "reconnect.cancel",
716
671
  message: "aborting reconnector"
717
672
  });
718
673
  this.reconnector?.cancel();
719
674
  this.gateway?.disconnect();
720
- await this.queue.drain();
721
675
  this.gateway = null;
722
- this.handler = null;
723
- this.statusEmitter.set("disconnected");
724
676
  }
725
- status() {
726
- return this.statusEmitter.value;
727
- }
728
- hasWebSocket() {
729
- const result = attempt(() => Boolean(this.deps.WebSocket));
677
+ hasWebSocket(ctx) {
678
+ const result = attempt(() => Boolean(ctx.deps.WebSocket));
730
679
  if (result instanceof Error) {
731
680
  const error = safeNormalizeError({ value: result });
732
- this.log.error({
681
+ ctx.log.error({
733
682
  action: "deps.web-socket.read.error",
734
683
  message: safeErrorMessage({ error }),
735
684
  error
@@ -738,55 +687,45 @@ var FlumeDiscordSource = class {
738
687
  }
739
688
  return result;
740
689
  }
741
- async connectInternal(resumeUrl) {
742
- this.statusEmitter.set("connecting");
690
+ async connectInternal(ctx, resumeUrl) {
691
+ this.setStatus("connecting");
743
692
  this.gateway = new FlumeDiscordGateway({
744
693
  token: this.options.token,
745
694
  intents: this.options.intents ?? DEFAULT_INTENTS,
746
- onLog: this.options.onLog,
747
- deps: this.deps,
748
- onDispatch: (eventName, eventData) => this.handleDispatch(eventName, eventData),
749
- onStatus: (status) => this.handleGatewayStatus(status)
695
+ onLog: ctx.log.handler,
696
+ deps: ctx.deps,
697
+ onDispatch: (eventName, eventData) => this.dispatch(ctx, eventName, eventData),
698
+ onStatus: (status) => this.handleGatewayStatus(ctx, status)
750
699
  });
751
700
  const error = await this.gateway.connect(resumeUrl);
752
701
  if (error instanceof FlumeConnectionError) {
753
- this.log.error({
702
+ ctx.log.error({
754
703
  action: "connect.failed",
755
704
  message: safeErrorMessage({ error }),
756
705
  error
757
706
  });
758
707
  if (this.gateway.isStopped || !this.reconnector || this.reconnector.aborted) {
759
- this.statusEmitter.set("disconnected");
708
+ this.setStatus("disconnected");
760
709
  return error;
761
710
  }
762
- this.scheduleReconnect();
711
+ this.scheduleReconnect(ctx);
763
712
  }
764
713
  return null;
765
714
  }
766
- handleDispatch(eventName, eventData) {
767
- const handler = this.handler;
768
- if (!handler) return;
769
- this.queue.add(async () => {
770
- const event = {
771
- source: "discord",
772
- type: eventName,
773
- data: eventData,
774
- meta: this.safeExtractMeta(eventName, eventData),
775
- receivedAt: safeNow({ deps: this.deps })
776
- };
777
- const r = await attempt(() => Promise.resolve(handler(event)));
778
- if (r instanceof Error) this.log.error({
779
- action: "handler.error",
780
- message: safeErrorMessage({ error: r }),
781
- error: r
782
- });
715
+ dispatch(ctx, eventName, eventData) {
716
+ this.emit({
717
+ source: "discord",
718
+ type: eventName,
719
+ data: eventData,
720
+ meta: this.safeExtractMeta(ctx, eventName, eventData),
721
+ receivedAt: safeNow({ deps: ctx.deps })
783
722
  });
784
723
  }
785
- safeExtractMeta(eventName, eventData) {
724
+ safeExtractMeta(ctx, eventName, eventData) {
786
725
  const result = attempt(() => flumeExtractDiscordMeta(eventName, eventData));
787
726
  if (result instanceof Error) {
788
727
  const error = safeNormalizeError({ value: result });
789
- this.log.warn({
728
+ ctx.log.warn({
790
729
  action: "meta.extract.error",
791
730
  message: safeErrorMessage({ error }),
792
731
  error,
@@ -796,37 +735,37 @@ var FlumeDiscordSource = class {
796
735
  }
797
736
  return result;
798
737
  }
799
- handleGatewayStatus(status) {
738
+ handleGatewayStatus(ctx, status) {
800
739
  if (status === "connected") {
801
- if (this.reconnector && this.reconnector.attempt > 0) this.log.info({
740
+ if (this.reconnector && this.reconnector.attempt > 0) ctx.log.info({
802
741
  action: "reconnect.reset",
803
742
  message: `cleared ${this.reconnector.attempt} attempts`
804
743
  });
805
744
  this.reconnector?.reset();
806
- this.statusEmitter.set("connected");
745
+ this.setStatus("connected");
807
746
  return;
808
747
  }
809
748
  if (this.gateway?.isStopped) {
810
- this.statusEmitter.set("disconnected");
749
+ this.setStatus("disconnected");
811
750
  return;
812
751
  }
813
- this.scheduleReconnect();
752
+ this.scheduleReconnect(ctx);
814
753
  }
815
- scheduleReconnect() {
754
+ scheduleReconnect(ctx) {
816
755
  const url = this.gateway?.session.resumeUrl ?? void 0;
817
756
  scheduleFlumeReconnect({
818
757
  reconnector: this.reconnector,
819
- log: this.log,
820
- setStatus: (status) => this.statusEmitter.set(status),
758
+ log: ctx.log,
759
+ setStatus: (status) => this.setStatus(status),
821
760
  retry: () => {
822
- this.connectInternal(url).catch((err) => {
761
+ this.connectInternal(ctx, url).catch((err) => {
823
762
  const error = safeNormalizeError({ value: err });
824
- this.log.error({
763
+ ctx.log.error({
825
764
  action: "reconnect.unhandled",
826
765
  message: safeErrorMessage({ error }),
827
766
  error
828
767
  });
829
- this.statusEmitter.set("disconnected");
768
+ this.setStatus("disconnected");
830
769
  });
831
770
  }
832
771
  });