@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 +129 -86
- package/dist/discord.d.ts +5 -13
- package/dist/discord.js +46 -107
- package/dist/flume-source-DUvt9aJt.js +341 -0
- package/dist/flume-source-DuUFPhSe.d.ts +228 -0
- package/dist/github.d.ts +4 -12
- package/dist/github.js +24 -84
- package/dist/index.d.ts +26 -38
- package/dist/index.js +92 -18
- package/dist/safe-json-parse-CfJjt-RY.js +11 -0
- package/dist/{safe-read-text-DgrJ4Uhl.js → safe-read-text-JQd_5vbd.js} +1 -1
- package/dist/{safe-stringify-BWS-uXZP.js → safe-stringify-DbWQw9qe.js} +2 -17
- package/dist/slack.d.ts +5 -13
- package/dist/slack.js +56 -114
- package/package.json +2 -1
- package/dist/safe-invoke-callback-EpWXwfwp.js +0 -170
- package/dist/serial-queue-B9LoBc64.js +0 -162
- package/dist/types-D-tO-Mh2.d.ts +0 -154
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(
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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!,
|
|
34
|
+
new FlumeGitHubSource({ token: process.env.GITHUB_TOKEN!, pollInterval: 60 }),
|
|
39
35
|
],
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
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(
|
|
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
|
-
##
|
|
94
|
+
## Sub-entries
|
|
84
95
|
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
## Custom sources
|
|
105
111
|
|
|
106
|
-
|
|
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 {
|
|
115
|
-
import {
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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`
|
|
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
|
-
-
|
|
179
|
-
-
|
|
180
|
-
-
|
|
181
|
-
-
|
|
182
|
-
-
|
|
183
|
-
-
|
|
184
|
-
-
|
|
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`
|
|
236
|
+
`reconnect` on `Flume` is an options object — omit it to disable reconnects entirely.
|
|
201
237
|
|
|
202
238
|
```ts
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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`
|
|
267
|
+
Pass an `AbortSignal` to `Flume` — it propagates to every source via the auto-stop pathway.
|
|
223
268
|
|
|
224
|
-
|
|
269
|
+
```ts
|
|
225
270
|
const controller = new AbortController()
|
|
226
271
|
|
|
227
|
-
const flume = new Flume({
|
|
228
|
-
|
|
272
|
+
const flume = new Flume([new FlumeDiscordSource({ token })], {
|
|
273
|
+
onEvent: (event) => { ... },
|
|
229
274
|
signal: controller.signal,
|
|
230
275
|
})
|
|
231
276
|
|
|
232
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
-
|
|
261
|
-
-
|
|
262
|
-
-
|
|
263
|
-
-
|
|
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`;
|
|
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()`
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
- `
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
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
|
|
4
|
-
import {
|
|
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
|
|
697
|
-
if (this.
|
|
698
|
-
if (!this.
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
709
|
-
this.
|
|
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
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
747
|
-
deps:
|
|
748
|
-
onDispatch: (eventName, eventData) => this.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
745
|
+
this.setStatus("connected");
|
|
807
746
|
return;
|
|
808
747
|
}
|
|
809
748
|
if (this.gateway?.isStopped) {
|
|
810
|
-
this.
|
|
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:
|
|
820
|
-
setStatus: (status) => this.
|
|
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
|
-
|
|
763
|
+
ctx.log.error({
|
|
825
764
|
action: "reconnect.unhandled",
|
|
826
765
|
message: safeErrorMessage({ error }),
|
|
827
766
|
error
|
|
828
767
|
});
|
|
829
|
-
this.
|
|
768
|
+
this.setStatus("disconnected");
|
|
830
769
|
});
|
|
831
770
|
}
|
|
832
771
|
});
|