@interactive-inc/flume 0.3.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
@@ -1,10 +1,10 @@
1
1
  # open-flume
2
2
 
3
- Unified notification listener for Discord, Slack, and GitHub. Raw WebSocket + `fetch` + Zod. Zero SDK dependencies — no `discord.js`, no `@slack/bolt`, no `@slack/web-api`. Runs on Node 18+, Bun, Deno, Cloudflare Workers, or any environment with global `fetch` and `WebSocket`.
3
+ Unified notification listener for Discord, Slack, and GitHub. Raw WebSocket + `fetch` + Zod. Zero SDK dependencies — no `discord.js`, no `@slack/bolt`, no `@slack/web-api`. ESM only (`require()` not supported). Runs on Node 22+, Bun, Deno, Cloudflare Workers, or any environment with global `fetch` and `WebSocket` (the GitHub source only needs `fetch`).
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,28 +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 }),
32
- new FlumeSlackSource({ appToken: process.env.SLACK_APP_TOKEN!, botToken: process.env.SLACK_BOT_TOKEN!, onLog, reconnect: true }),
33
- new FlumeGitHubSource({ token: process.env.GITHUB_TOKEN!, onLog, pollInterval: 60 }),
27
+ const flume = new Flume(
28
+ [
29
+ new FlumeDiscordSource({ token: process.env.DISCORD_BOT_TOKEN! }),
30
+ new FlumeSlackSource({
31
+ appToken: process.env.SLACK_APP_TOKEN!,
32
+ botToken: process.env.SLACK_BOT_TOKEN!,
33
+ }),
34
+ new FlumeGitHubSource({ token: process.env.GITHUB_TOKEN!, pollInterval: 60 }),
34
35
  ],
35
- })
36
-
37
- const result = await flume.start((event) => {
38
- console.log(event.source, event.type, event.meta)
39
- })
40
-
41
- if (!result.ok) throw result.error
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
+ )
42
45
 
43
- const running = flume.runningState()!
46
+ const running = await flume.start()
47
+ if (running instanceof Error) throw running
44
48
 
45
49
  // later
46
50
  await running.stop()
47
51
  ```
48
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
+
49
63
  ## Lifecycle (type-state FSM)
50
64
 
51
65
  `Flume` enforces lifecycle correctness through three classes — misuse becomes a compile error.
@@ -55,93 +69,133 @@ Flume ──start()──▶ FlumeRunning ──stop()──▶ FlumeStopped
55
69
  (idle) (running) (terminal)
56
70
  ```
57
71
 
58
- - `Flume.start(handler)` returns `FlumeStartResult` — a discriminated union `{ ok: true } | { ok: false; error: Error }`. On `ok: true`, the `FlumeRunning` instance is reachable via `flume.runningState()`. On partial failure (one source fails while another succeeds), the already-started sources are rolled back and `ok: false` is returned with per-source detail in `error.message`.
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.
59
73
  - `FlumeRunning.stop()` returns a `FlumeStopped` snapshot. `stop()` is idempotent and concurrent-safe.
60
74
  - `FlumeStopped` exposes only `statuses()` — a frozen snapshot of each source's final state. No `start`, no `stop`, no leaking source references.
61
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.
62
77
 
63
78
  ```ts
64
- const result = await flume.start(handler)
65
- if (!result.ok) {
66
- console.error(result.error.message)
79
+ const running = await flume.start()
80
+ if (running instanceof Error) {
81
+ console.error(running.message)
67
82
  // "Flume.start: 1 source(s) failed: slack: connect refused"
68
83
  return
69
84
  }
70
85
 
71
- const running = flume.runningState()!
72
-
73
86
  running.start() // type error — `start` is not on FlumeRunning
74
87
 
75
88
  const stopped = await running.stop()
76
- stopped.stop() // type error
89
+ stopped.stop() // type error
77
90
  stopped.start() // type error
78
- stopped.statuses() // [{ name: "discord", status: "disconnected" }, ...]
91
+ stopped.statuses() // [{ source: "discord", status: "disconnected" }, ...]
79
92
  ```
80
93
 
81
- ## Direct source usage
94
+ ## Sub-entries
82
95
 
83
- 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.
84
97
 
85
98
  ```ts
99
+ import { Flume, FlumeSource } from "@interactive-inc/flume"
86
100
  import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
87
-
88
- const source = new FlumeDiscordSource({
89
- token: process.env.DISCORD_BOT_TOKEN!,
90
- reconnect: true,
91
- onLog: (log) => console.log(log),
92
- })
93
-
94
- const result = await source.start((event) => { /* ... */ })
95
- if (!result.ok) throw result.error
101
+ import { FlumeSlackSource } from "@interactive-inc/flume/slack"
102
+ import { FlumeGitHubSource } from "@interactive-inc/flume/github"
96
103
  ```
97
104
 
98
- ## 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`
99
109
 
100
- 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
101
111
 
102
- | sub-entry | exports |
103
- |------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
104
- | `@interactive-inc/flume` | `Flume`, `FlumeRunning`, `FlumeStopped`, `FlumeLogger`, `FlumeReconnector`, `createFlumeDefaultDeps`, errors, types |
105
- | `@interactive-inc/flume/discord` | `FlumeDiscordSource`, `FlumeDiscordGateway`, `FlumeDiscordGatewayIntents`, `FlumeDiscordHeartbeat`, `FlumeDiscordGatewaySession`, `parseDiscordGatewayMessage`, `extractDiscordMeta`, `FlumeGatewayMessageSchema` |
106
- | `@interactive-inc/flume/slack` | `FlumeSlackSource`, `FlumeSlackSocketMode`, `FlumeSlackSeenCache`, `obtainSlackUrl`, `extractSlackMeta`, `FlumeSlackEnvelopeSchema`, `FlumeSlackConnectionResponseSchema` |
107
- | `@interactive-inc/flume/github` | `FlumeGitHubSource`, `FlumeGitHubPoller`, `FlumeGitHubSeenCache`, `extractGitHubMeta`, `FlumeGitHubNotificationSchema` |
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).
108
113
 
109
114
  ```ts
110
- import { Flume } from "@interactive-inc/flume"
111
- import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
112
- import { FlumeSlackSource } from "@interactive-inc/flume/slack"
113
- 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
+ }
114
154
  ```
115
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
+
116
158
  ## Event shape
117
159
 
118
- Every source emits the same `FlumeEvent`:
160
+ Every source emits the same `FlumeEvent` — a discriminated union keyed on `source` so `data` narrows automatically:
119
161
 
120
162
  ```ts
121
- type FlumeSourceName = "discord" | "slack" | "github"
122
-
123
- type FlumeEvent = {
124
- source: FlumeSourceName
163
+ type FlumeDiscordEvent = {
164
+ source: "discord"
165
+ type: string
166
+ data: Record<string, unknown>
167
+ meta: Record<string, string>
168
+ receivedAt: number
169
+ }
170
+ type FlumeSlackEvent = {
171
+ source: "slack"
125
172
  type: string
126
- data: unknown
173
+ data: Record<string, unknown>
127
174
  meta: Record<string, string>
128
175
  receivedAt: number
129
176
  }
177
+ type FlumeGitHubEvent = {
178
+ source: "github"
179
+ type: "notification"
180
+ data: FlumeGitHubNotification
181
+ meta: Record<string, string>
182
+ receivedAt: number
183
+ }
184
+
185
+ type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent
130
186
  ```
131
187
 
132
188
  `meta` is flat string keys tailored per source:
133
189
 
134
- | source | meta keys |
135
- |---------|------------------------------------------------------------------------|
136
- | discord | `event_type`, `channel_id`, `guild_id`, `user_id` |
137
- | slack | `event_type`, `channel_id`, `user_id`, `thread_ts`, `slack_event_type` |
138
- | 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`
139
193
 
140
194
  `data` is the raw parsed payload (Zod-validated at the protocol boundary).
141
195
 
142
196
  ## Observability
143
197
 
144
- 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`.
145
199
 
146
200
  ```ts
147
201
  type FlumeLog = {
@@ -157,13 +211,13 @@ type FlumeLog = {
157
211
 
158
212
  What gets logged:
159
213
 
160
- - **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.
161
- - **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`.
162
- - **Protocol lifecycle** — Discord HELLO / READY / RESUMED / RECONNECT / INVALID_SESSION / HEARTBEAT / HEARTBEAT_ACK, Slack hello / disconnect / envelope ack, GitHub bootstrap / fresh / idle.
163
- - **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`.
164
- - **Reconnect** — `reconnect.scheduled` (with delay in ms), `reconnect.exhausted` (with attempt count), `reconnect.reset` (on successful connect after retries), `reconnect.cancel` (on stop).
165
- - **Status transitions** — `status` action with `previous → next`.
166
- - **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.
167
221
 
168
222
  Route it anywhere — Sentry, Datadog, `console`, a file — your choice:
169
223
 
@@ -179,58 +233,64 @@ onLog: (log) => {
179
233
 
180
234
  ## Reconnect
181
235
 
182
- `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.
183
237
 
184
238
  ```ts
185
- reconnect: {
186
- maxAttempts: 10,
187
- baseDelay: 1000, // first backoff
188
- maxDelay: 30000, // backoff cap
189
- }
239
+ new Flume(sources, {
240
+ onEvent: (event) => { ... },
241
+ reconnect: {
242
+ maxAttempts: 10,
243
+ baseDelay: 1000, // first backoff
244
+ maxDelay: 30000, // backoff cap
245
+ },
246
+ })
190
247
  ```
191
248
 
192
- 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.
193
250
 
194
251
  ## Status
195
252
 
196
253
  ```ts
197
- 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
198
261
  ```
199
262
 
200
263
  GitHub populates `detail` with the failure reason (e.g. `"HTTP 500"`, `"network error"`). Discord and Slack leave `detail` undefined.
201
264
 
202
265
  ## Cancellation
203
266
 
204
- 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.
205
268
 
206
269
  ```ts
207
270
  const controller = new AbortController()
208
271
 
209
- const flume = new Flume({
210
- sources: [new FlumeDiscordSource({ token, signal: controller.signal })],
272
+ const flume = new Flume([new FlumeDiscordSource({ token })], {
273
+ onEvent: (event) => { ... },
211
274
  signal: controller.signal,
212
275
  })
213
276
 
214
- ```ts
215
- const result = await flume.start(handler)
216
- if (!result.ok) throw result.error
217
-
218
- const running = flume.runningState()!
277
+ const running = await flume.start()
278
+ if (running instanceof Error) throw running
219
279
 
220
280
  controller.abort() // FlumeRunning auto-transitions to FlumeStopped
221
281
  ```
222
282
 
223
- If the signal is already aborted at `Flume.start()` time, `start` returns `{ ok: false, error }` and no source is touched.
283
+ If the signal is already aborted at `Flume.start()` time, `start` returns a `FlumeStartError` and no source is touched.
224
284
 
225
285
  ## Dependency injection
226
286
 
227
- 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.
228
288
 
229
289
  ```ts
230
290
  import { createFlumeDefaultDeps } from "@interactive-inc/flume"
231
291
 
232
- new FlumeDiscordSource({
233
- token,
292
+ new Flume([new FlumeDiscordSource({ token })], {
293
+ onEvent: (event) => { ... },
234
294
  deps: {
235
295
  ...createFlumeDefaultDeps(),
236
296
  fetch: mockFetch,
@@ -241,28 +301,29 @@ new FlumeDiscordSource({
241
301
 
242
302
  ## Safety
243
303
 
244
- - **Backpressure** — each source has its own `FlumeSerialQueue`. 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.
245
- - **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.
246
- - **Partial-failure rollback** — if any source fails during `Flume.start()`, the already-started sources are stopped and `{ ok: false, error }` is returned with per-source detail.
247
- - **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()`.
248
308
 
249
309
  ## Errors
250
310
 
251
- Flume does not throw on protocol/network failures. `Source.start()` and `Flume.start()` return `FlumeStartResult` (`{ ok: true } | { ok: false; error: Error }`) branch on `result.ok`. Protocol-layer helpers (`FlumeDiscordGateway.connect()`, `obtainSlackUrl()`, …) return `T | Error` and you check `instanceof`:
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`:
252
312
 
313
+ - `FlumeStartError` — `Flume.start()` refused or failed (already started, signal aborted, partial-failure rollback)
253
314
  - `FlumeConnectionError` — WebSocket closed before ready
254
315
  - `FlumeHttpError` — HTTP call returned an error payload (e.g. Slack `ok: false`)
255
316
  - `FlumeParseError` — Unparseable WebSocket frame
256
317
 
257
- 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).
319
+
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.
258
321
 
259
322
  ## Supported sources
260
323
 
261
- | source | transport | auth |
262
- |---------|----------------------------------|-----------------------------------------------|
263
- | Discord | Gateway WebSocket v10 (JSON) | bot token |
264
- | Slack | Socket Mode WebSocket | app token + bot token (both required) |
265
- | 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
266
327
 
267
328
  GitHub also exposes `gh auth token` if you want to reuse the `gh` CLI's session:
268
329
 
@@ -275,15 +336,11 @@ const github = new FlumeGitHubSource({ token })
275
336
  ## Module layout
276
337
 
277
338
  - `Flume` / `FlumeRunning` / `FlumeStopped` — type-state FSM merging multiple sources into one stream
278
- - `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` high-level sources (each conforms to the structural `FlumeSource` type)
279
- - `FlumeDiscordGateway` / `FlumeSlackSocketMode` / `FlumeGitHubPoller` — protocol layer
280
- - `FlumeDiscordGatewaySession` — immutable session value object (id / seq / resume URL) carried across Discord reconnects
281
- - `FlumeSlackSeenCache` / `FlumeGitHubSeenCache` — per-source duplicate suppression
282
- - `FlumeReconnector` exponential backoff with jitter (the internal `scheduleFlumeReconnect` helper is not exported; sources wire it themselves)
283
- - `FlumeLogger` — structured log emitter (feeds `onLog`)
284
- - `FlumeRuntimeDeps` — IO boundary port (`fetch`, `WebSocket`, `now`, `random`, timers)
285
- - `extractDiscordMeta` / `extractSlackMeta` / `extractGitHubMeta` — pure functions that build `FlumeEvent.meta` from each protocol's payload shape
286
- - Per-source Zod schemas: `FlumeGatewayMessageSchema` (discord), `FlumeSlackEnvelopeSchema` / `FlumeSlackConnectionResponseSchema` (slack), `FlumeGitHubNotificationSchema` (github)
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`
341
+ - `FlumeRuntimeDeps` — IO boundary port (`fetch`, `WebSocket`, `now`, `random`, timers); `WebSocket` is nullable for fetch-only runtimes
342
+ - `flumeExtractDiscordMeta` / `flumeExtractSlackMeta` / `flumeExtractGitHubMeta` pure functions that build `FlumeEvent.meta` from each protocol's payload shape
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
287
344
 
288
345
  ## Development
289
346
 
@@ -296,7 +353,7 @@ bunx vp lint # lint
296
353
  bunx vp fmt # format
297
354
  ```
298
355
 
299
- 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 18+, Bun, Deno, Cloudflare Workers, or modern browsers.
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`.
300
357
 
301
358
  ## License
302
359
 
@@ -0,0 +1,16 @@
1
+ //#region lib/errors/connection-error.ts
2
+ /**
3
+ * 接続失敗を表す。Discord Gateway / Slack Socket Mode / その他 WebSocket 系の close で発生。
4
+ * `code` は接続が落ちた際の close code (Discord は 4xxx 帯が再接続可否を示す)
5
+ */
6
+ var FlumeConnectionError = class extends Error {
7
+ code;
8
+ constructor(message, options) {
9
+ super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
10
+ this.name = "FlumeConnectionError";
11
+ this.code = options?.code ?? null;
12
+ Object.freeze(this);
13
+ }
14
+ };
15
+ //#endregion
16
+ export { FlumeConnectionError as t };
package/dist/discord.d.ts CHANGED
@@ -1,86 +1,20 @@
1
- import { A as FlumeGatewayMessageSchema, C as FlumeStartResult, c as FlumeLogHandler, o as FlumeHandler, p as FlumeRuntimeDeps, r as FlumeGatewayMessage, t as FlumeDiscordSourceOptions, w as FlumeStatus } from "./types-Bm9uKUQz.js";
2
- import { t as FlumeConnectionError } from "./connection-error-BOk97djj.js";
3
- import { t as FlumeParseError } from "./parse-error-BAiCLRmk.js";
1
+ import { C as FlumeSourceStartContext, r as FlumeDiscordSourceOptions, t as FlumeSource } from "./flume-source-DuUFPhSe.js";
4
2
 
5
3
  //#region lib/discord/discord-source.d.ts
6
- declare class FlumeDiscordSource {
4
+ declare class FlumeDiscordSource extends FlumeSource {
7
5
  private readonly options;
8
6
  readonly name: "discord";
9
7
  private gateway;
10
8
  private reconnector;
11
- private handler;
12
- private currentStatus;
13
- private readonly log;
14
- private readonly deps;
15
- private readonly queue;
16
9
  constructor(options: FlumeDiscordSourceOptions);
17
- start(handler: FlumeHandler): Promise<FlumeStartResult>;
18
- stop(): Promise<void>;
19
- status(): FlumeStatus;
10
+ protected connect(ctx: FlumeSourceStartContext): Promise<Error | null>;
11
+ protected disconnect(): void;
12
+ private hasWebSocket;
20
13
  private connectInternal;
21
- private handleDispatch;
14
+ private dispatch;
15
+ private safeExtractMeta;
22
16
  private handleGatewayStatus;
23
17
  private scheduleReconnect;
24
- private setStatus;
25
- }
26
- //#endregion
27
- //#region lib/discord/extract-discord-meta.d.ts
28
- declare function extractDiscordMeta(eventName: string, eventData: Record<string, unknown>): Record<string, string>;
29
- //#endregion
30
- //#region lib/discord/discord-gateway-session.d.ts
31
- type Props$2 = {
32
- sessionId: string | null;
33
- resumeUrl: string | null;
34
- seq: number | null;
35
- };
36
- declare class FlumeDiscordGatewaySession {
37
- readonly sessionId: string | null;
38
- readonly resumeUrl: string | null;
39
- readonly seq: number | null;
40
- constructor(props: Props$2);
41
- static empty(): FlumeDiscordGatewaySession;
42
- canResume(): boolean;
43
- withSeq(seq: number): FlumeDiscordGatewaySession;
44
- withReady(sessionId: string, resumeUrl: string): FlumeDiscordGatewaySession;
45
- withReset(): FlumeDiscordGatewaySession;
46
- }
47
- //#endregion
48
- //#region lib/discord/discord-gateway.d.ts
49
- type Deps = Pick<FlumeRuntimeDeps, "WebSocket" | "setInterval" | "clearInterval" | "setTimeout" | "random" | "now">;
50
- type Props$1 = {
51
- token: string;
52
- intents: number;
53
- onDispatch: (event: string, data: Record<string, unknown>) => void;
54
- onStatus: (status: "connected" | "disconnected") => void;
55
- onLog?: FlumeLogHandler;
56
- deps: Deps;
57
- };
58
- declare class FlumeDiscordGateway {
59
- private readonly props;
60
- private readonly log;
61
- private ws;
62
- private heartbeat;
63
- session: FlumeDiscordGatewaySession;
64
- stopped: boolean;
65
- private pendingResolve;
66
- private pendingResolved;
67
- constructor(props: Props$1);
68
- connect(url?: string): Promise<FlumeConnectionError | null>;
69
- disconnect(): void;
70
- isConnected(): boolean;
71
- private completeConnect;
72
- private onMessage;
73
- private onHello;
74
- private onHeartbeatAck;
75
- private onHeartbeatRequest;
76
- private onReconnectRequest;
77
- private onInvalidSession;
78
- private onDispatch;
79
- private onClose;
80
- private onError;
81
- private send;
82
- private sendIdentify;
83
- private sendResume;
84
18
  }
85
19
  //#endregion
86
20
  //#region lib/discord/discord-gateway-intents.d.ts
@@ -108,24 +42,7 @@ declare const FlumeDiscordGatewayIntents: {
108
42
  readonly DirectMessagePolls: number;
109
43
  };
110
44
  //#endregion
111
- //#region lib/discord/discord-heartbeat.d.ts
112
- type Props = {
113
- onSend: () => void;
114
- onZombie: () => void;
115
- deps: Pick<FlumeRuntimeDeps, "setInterval" | "clearInterval">;
116
- };
117
- declare class FlumeDiscordHeartbeat {
118
- private readonly props;
119
- private timer;
120
- private ackReceived;
121
- constructor(props: Props);
122
- start(intervalMs: number): void;
123
- stop(): void;
124
- ack(): void;
125
- isRunning(): boolean;
126
- }
127
- //#endregion
128
- //#region lib/discord/parse-discord-gateway-message.d.ts
129
- declare function parseDiscordGatewayMessage(raw: string): FlumeGatewayMessage | FlumeParseError;
45
+ //#region lib/discord/extract-discord-meta.d.ts
46
+ declare function flumeExtractDiscordMeta(eventName: string, eventData: Record<string, unknown>): Record<string, string>;
130
47
  //#endregion
131
- export { FlumeDiscordGateway, FlumeDiscordGatewayIntents, FlumeDiscordGatewaySession, FlumeDiscordHeartbeat, FlumeDiscordSource, FlumeGatewayMessageSchema, extractDiscordMeta, parseDiscordGatewayMessage };
48
+ export { FlumeDiscordGatewayIntents, FlumeDiscordSource, flumeExtractDiscordMeta };