@interactive-inc/flume 0.4.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +232 -95
- package/dist/discord.d.ts +5 -13
- package/dist/discord.js +48 -108
- package/dist/flume-source.d.ts +269 -0
- package/dist/flume-source.js +344 -0
- package/dist/github.d.ts +4 -12
- package/dist/github.js +33 -88
- package/dist/index.d.ts +133 -42
- package/dist/index.js +340 -59
- package/dist/is-record.js +6 -0
- package/dist/parse-error.d.ts +9 -0
- package/dist/safe-json-parse.js +11 -0
- package/dist/{safe-read-text-DgrJ4Uhl.js → safe-read-text.js} +2 -2
- package/dist/{safe-stringify-BWS-uXZP.js → safe-stringify.js} +7 -28
- package/dist/slack.d.ts +5 -13
- package/dist/slack.js +65 -118
- package/dist/time.d.ts +45 -0
- package/dist/time.js +319 -0
- package/package.json +7 -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/dist/{connection-error-HUO3PC3G.js → connection-error.js} +0 -0
- /package/dist/{http-error-CPSKoSie.js → http-error.js} +0 -0
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 ──
|
|
7
|
+
Slack ─┼──▶ Flume ──open()──▶ FlumeEvent (one merged stream)
|
|
8
8
|
GitHub ─┘
|
|
9
9
|
```
|
|
10
10
|
|
|
@@ -24,29 +24,40 @@ import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
|
|
|
24
24
|
import { FlumeSlackSource } from "@interactive-inc/flume/slack"
|
|
25
25
|
import { FlumeGitHubSource } from "@interactive-inc/flume/github"
|
|
26
26
|
|
|
27
|
-
const onLog = (log) => console.log(`[${log.level}] ${log.source}/${log.action}: ${log.message}`)
|
|
28
|
-
|
|
29
27
|
const flume = new Flume({
|
|
30
28
|
sources: [
|
|
31
|
-
new FlumeDiscordSource({ token: process.env.DISCORD_BOT_TOKEN
|
|
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
|
],
|
|
36
|
+
onEvent: (item) => {
|
|
37
|
+
// single firehose: events + every log, discriminated by `kind`
|
|
38
|
+
if (item.kind === "event") console.log(item.event.source, item.event.type)
|
|
39
|
+
if (item.kind === "log") console.log(`[${item.log.level}] ${item.log.action}`)
|
|
40
|
+
},
|
|
41
|
+
onError: (log) => Sentry.captureException(log.error ?? new Error(log.message)),
|
|
42
|
+
reconnect: { maxAttempts: 10 },
|
|
40
43
|
})
|
|
41
44
|
|
|
42
|
-
const running = await flume.
|
|
43
|
-
console.log(event.source, event.type, event.meta)
|
|
44
|
-
})
|
|
45
|
+
const running = await flume.open()
|
|
45
46
|
|
|
46
47
|
if (running instanceof Error) throw running
|
|
47
48
|
|
|
48
49
|
// later
|
|
49
|
-
await running.
|
|
50
|
+
await running.close()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`new Flume({ sources, ...options })` — a single options object; `sources` is the only required field, everything else is optional. There is one unified firehose: `onEvent` (push) and `FlumeRunning.stream()` (pull) both deliver the same `FlumeStreamItem` — received events **and** every log (status transitions, errors, debug) merged into one stream. The consumer filters by `item.kind` (`"event"` / `"log"`) and `item.log.level`. This is built for piping the whole picture into an agent (Claude / Codex) so it notices disconnects on its own. `onError` is a convenience filter that additionally receives only the `level: "error"` logs (route it straight to Sentry).
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// Minimum: just open the protocols and discard everything.
|
|
57
|
+
const flume = new Flume({ sources: [new FlumeDiscordSource({ token })] })
|
|
58
|
+
|
|
59
|
+
// Errors-only: forward failures to Sentry, ignore the rest.
|
|
60
|
+
const flume = new Flume({ sources: [new FlumeDiscordSource({ token })], onError })
|
|
50
61
|
```
|
|
51
62
|
|
|
52
63
|
## Lifecycle (type-state FSM)
|
|
@@ -54,69 +65,176 @@ await running.stop()
|
|
|
54
65
|
`Flume` enforces lifecycle correctness through three classes — misuse becomes a compile error.
|
|
55
66
|
|
|
56
67
|
```
|
|
57
|
-
Flume ──
|
|
68
|
+
Flume ──open()──▶ FlumeRunning ──close()──▶ FlumeClosed
|
|
58
69
|
(idle) (running) (terminal)
|
|
59
70
|
```
|
|
60
71
|
|
|
61
|
-
- `Flume.
|
|
62
|
-
- `FlumeRunning.
|
|
63
|
-
- `
|
|
64
|
-
- An `AbortSignal` on `Flume` drives an automatic transition to `
|
|
65
|
-
- `FlumeRunning.kind === "running"` and `
|
|
72
|
+
- `Flume.open()` returns `FlumeRunning | FlumeStartError`. Branch with `instanceof Error`. On partial failure (one source fails while another succeeds), the already-opened sources are rolled back and a `FlumeStartError` is returned with per-source detail in `.message`. Calling `open()` a second time on the same `Flume` instance returns `FlumeStartError` at runtime — the type system also rejects calling `open()` on the returned `FlumeRunning`/`FlumeClosed` handles.
|
|
73
|
+
- `FlumeRunning.close()` returns a `FlumeClosed` snapshot. `close()` is idempotent and concurrent-safe.
|
|
74
|
+
- `FlumeClosed` exposes only `statuses()` — a frozen snapshot of each source's final state. No `open`, no `close`, no leaking source references.
|
|
75
|
+
- An `AbortSignal` on `Flume` drives an automatic transition to `FlumeClosed`.
|
|
76
|
+
- `FlumeRunning.kind === "running"` and `FlumeClosed.kind === "closed"` provide a runtime discriminator when generic code holds the union.
|
|
66
77
|
|
|
67
78
|
```ts
|
|
68
|
-
const running = await flume.
|
|
79
|
+
const running = await flume.open()
|
|
69
80
|
if (running instanceof Error) {
|
|
70
81
|
console.error(running.message)
|
|
71
|
-
// "Flume.
|
|
82
|
+
// "Flume.open: 1 source(s) failed: slack: connect refused"
|
|
72
83
|
return
|
|
73
84
|
}
|
|
74
85
|
|
|
75
|
-
running.
|
|
86
|
+
running.open() // type error — `open` is not on FlumeRunning
|
|
76
87
|
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
const closed = await running.close()
|
|
89
|
+
closed.close() // type error
|
|
90
|
+
closed.open() // type error
|
|
91
|
+
closed.statuses() // [{ source: "discord", status: "disconnected" }, ...]
|
|
81
92
|
```
|
|
82
93
|
|
|
83
|
-
##
|
|
94
|
+
## Dynamic groups
|
|
84
95
|
|
|
85
|
-
|
|
96
|
+
A `Flume` is single-use: its source set is fixed at construction and the FSM is terminal once closed. To add or drop sources at runtime, compose at a higher level with `FlumeConfluence` — it holds many `Flume` instances, merges all their firehoses into one `onEvent`, and lets you `add` / `remove` groups while the others keep running.
|
|
86
97
|
|
|
87
98
|
```ts
|
|
88
|
-
import {
|
|
99
|
+
import { FlumeConfluence } from "@interactive-inc/flume"
|
|
89
100
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
const confluence = new FlumeConfluence({
|
|
102
|
+
onEvent: (item) => {
|
|
103
|
+
if (item.kind === "event") feedToAgent(item.event)
|
|
104
|
+
if (item.kind === "log" && item.log.action === "status") noticeDisconnect(item.log)
|
|
105
|
+
},
|
|
106
|
+
reconnect: { maxAttempts: 10 },
|
|
94
107
|
})
|
|
95
108
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
await confluence.add("team-a", [new FlumeDiscordSource({ token: tokenA })])
|
|
110
|
+
await confluence.add("team-b", [new FlumeSlackSource({ appToken, botToken })]) // existing groups keep running
|
|
111
|
+
await confluence.remove("team-a") // stops only team-a
|
|
112
|
+
await confluence.closeAll()
|
|
100
113
|
```
|
|
101
114
|
|
|
115
|
+
`add(id, sources)` starts a fresh `Flume` for that group and returns `Error | null` (a duplicate id or a failed start is returned, never thrown). The `id` is just a management handle — the merged stream itself is untagged; identify the origin via the `source` field inside each item. Each group is an independent `Flume`, so a failure in one group never rolls back another. Reconstructing a group (rather than mutating one in place) loses only its reconnect counters, dedup caches, and Discord session resume — acceptable on a deliberate add/remove.
|
|
116
|
+
|
|
102
117
|
## Sub-entries
|
|
103
118
|
|
|
104
119
|
Each source has a dedicated entry — importing one does not pull the others into your bundle. The root entry never loads source-specific code.
|
|
105
120
|
|
|
106
|
-
| sub-entry | exports |
|
|
107
|
-
| -------------------------------- | -------------------------------------------------------------------------------- |
|
|
108
|
-
| `@interactive-inc/flume` | `Flume`, `FlumeRunning`, `FlumeStopped`, `createFlumeDefaultDeps`, errors, types |
|
|
109
|
-
| `@interactive-inc/flume/discord` | `FlumeDiscordSource`, `FlumeDiscordGatewayIntents`, `flumeExtractDiscordMeta` |
|
|
110
|
-
| `@interactive-inc/flume/slack` | `FlumeSlackSource`, `flumeExtractSlackMeta` |
|
|
111
|
-
| `@interactive-inc/flume/github` | `FlumeGitHubSource`, `flumeExtractGitHubMeta` |
|
|
112
|
-
|
|
113
121
|
```ts
|
|
114
|
-
import { Flume } from "@interactive-inc/flume"
|
|
122
|
+
import { Flume, FlumeSource } from "@interactive-inc/flume"
|
|
115
123
|
import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
|
|
116
124
|
import { FlumeSlackSource } from "@interactive-inc/flume/slack"
|
|
117
125
|
import { FlumeGitHubSource } from "@interactive-inc/flume/github"
|
|
126
|
+
import { FlumeTimeSource } from "@interactive-inc/flume/time"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
- `@interactive-inc/flume` — `Flume`, `FlumeConfluence`, `FlumeRunning`, `FlumeClosed`, `FlumeSource` (abstract base for third-party sources), `createFlumeDefaultDeps`, errors, types
|
|
130
|
+
- `@interactive-inc/flume/discord` — `FlumeDiscordSource`, `FlumeDiscordGatewayIntents`, `flumeExtractDiscordMeta`
|
|
131
|
+
- `@interactive-inc/flume/slack` — `FlumeSlackSource`, `flumeExtractSlackMeta`
|
|
132
|
+
- `@interactive-inc/flume/github` — `FlumeGitHubSource`, `flumeExtractGitHubMeta`
|
|
133
|
+
- `@interactive-inc/flume/time` — `FlumeTimeSource`, `parseCron`, `flumeCronNext`
|
|
134
|
+
|
|
135
|
+
## Custom sources
|
|
136
|
+
|
|
137
|
+
Extend `FlumeSource` to plug in any protocol. The base class owns `start`/`stop`/`status`, the per-source event queue, status emission (logged on every transition), and consumed/stopped guards. You implement `connect` (open the protocol, emit events, set status) and `disconnect` (tear it down).
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { FlumeSource } from "@interactive-inc/flume"
|
|
141
|
+
import type { FlumeSourceStartContext } from "@interactive-inc/flume"
|
|
142
|
+
|
|
143
|
+
class MyWebhookSource extends FlumeSource {
|
|
144
|
+
readonly name = "my-webhook"
|
|
145
|
+
|
|
146
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
147
|
+
|
|
148
|
+
constructor(private readonly options: { url: string; pollInterval?: number }) {
|
|
149
|
+
super()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
protected async connect(ctx: FlumeSourceStartContext): Promise<Error | null> {
|
|
153
|
+
this.setStatus("connecting")
|
|
154
|
+
const interval = (this.options.pollInterval ?? 30) * 1000
|
|
155
|
+
this.timer = ctx.deps.setInterval(() => this.poll(ctx), interval) as ReturnType<
|
|
156
|
+
typeof setInterval
|
|
157
|
+
>
|
|
158
|
+
this.setStatus("connected")
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
protected disconnect(): void {
|
|
163
|
+
if (this.timer) clearInterval(this.timer)
|
|
164
|
+
this.timer = null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private async poll(ctx: FlumeSourceStartContext): Promise<void> {
|
|
168
|
+
const res = await ctx.deps.fetch(this.options.url)
|
|
169
|
+
const payload = await res.json()
|
|
170
|
+
this.emit({
|
|
171
|
+
source: this.name as "discord", // declare your own discriminant via FlumeEvent extension
|
|
172
|
+
type: "webhook",
|
|
173
|
+
data: payload,
|
|
174
|
+
meta: { event_type: "webhook" },
|
|
175
|
+
receivedAt: ctx.deps.now(),
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`this.emit({...})` queues events through the base's serial queue and routes them to `ctx.onEvent` with `attempt()` isolation. `this.setStatus(...)` deduplicates idempotent transitions and logs the change (which is how status surfaces to consumers). Subclasses never need to write try/catch.
|
|
182
|
+
|
|
183
|
+
## Time source
|
|
184
|
+
|
|
185
|
+
`FlumeTimeSource` emits a `tick` event on a cron schedule. It holds no external connection, so it reaches `connected` as soon as it starts and is never a reconnect target. Useful as a heartbeat — a fixed marker in the same stream as your real sources.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { FlumeTimeSource } from "@interactive-inc/flume/time"
|
|
189
|
+
|
|
190
|
+
new Flume({
|
|
191
|
+
sources: [new FlumeTimeSource({ cron: "0 * * * *" })],
|
|
192
|
+
onEvent: (item) => console.log(item), // fires at minute 0 of every hour
|
|
193
|
+
})
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The cron expression is a standard 5-field spec (`minute hour day-of-month month day-of-week`) evaluated against the wall clock (local time). Supported per field: `*`, `*/n`, `a`, `a-b`, `a-b/n`, and comma lists. Day-of-week accepts `0-7` (`7` and `0` both mean Sunday). When both day-of-month and day-of-week are restricted, a day matches if either matches (standard cron semantics).
|
|
197
|
+
|
|
198
|
+
`message()` customizes each tick. Omitted fields fall back to the defaults (`type: "tick"`, `data: { firedAt, cron }`, `meta: { cron }`):
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
new FlumeTimeSource({
|
|
202
|
+
cron: "*/15 * * * *",
|
|
203
|
+
message: (tick) => ({
|
|
204
|
+
type: "heartbeat",
|
|
205
|
+
data: { at: new Date(tick.firedAt).toISOString() },
|
|
206
|
+
meta: { channel: "ops" },
|
|
207
|
+
}),
|
|
208
|
+
})
|
|
118
209
|
```
|
|
119
210
|
|
|
211
|
+
`tick.firedAt` is the scheduled wall-clock time (epoch ms), not the exact `setTimeout` firing instant. A throwing `message()` is isolated and falls back to the defaults. Parse the cron up front with `parseCron(expr)` (returns `FlumeCron | FlumeParseError`) or compute the next fire with `flumeCronNext(cron, afterMs)`.
|
|
212
|
+
|
|
213
|
+
## Pull stream
|
|
214
|
+
|
|
215
|
+
`FlumeRunning.stream()` is the pull form of the same firehose as `onEvent` — an async iterator over `FlumeStreamItem`, handy for feeding an agent (Claude / Codex) with `for await`, where backpressure falls out naturally from how fast you pull.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
type FlumeStreamItem = { kind: "event"; event: FlumeEvent } | { kind: "log"; log: FlumeLog }
|
|
219
|
+
|
|
220
|
+
const running = await flume.open()
|
|
221
|
+
if (running instanceof Error) throw running
|
|
222
|
+
|
|
223
|
+
for await (const item of running.stream()) {
|
|
224
|
+
if (item.kind === "event") await handleWithAgent(item.event)
|
|
225
|
+
if (item.kind === "log" && item.log.action === "status") noticeDisconnect(item.log)
|
|
226
|
+
}
|
|
227
|
+
// loop ends when the flume stops (running.close() or signal abort)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The iterator ends cleanly when the flume stops; `break`ing out unsubscribes automatically. When a slow consumer lets the buffer overflow, the oldest items are dropped by default:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
running.stream({ buffer: 5000, onOverflow: "drop-newest" })
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`stream()` (pull) and `onEvent` (push) carry the same items — use either or both. Multiple `stream()` consumers each get their own buffer.
|
|
237
|
+
|
|
120
238
|
## Event shape
|
|
121
239
|
|
|
122
240
|
Every source emits the same `FlumeEvent` — a discriminated union keyed on `source` so `data` narrows automatically:
|
|
@@ -143,23 +261,29 @@ type FlumeGitHubEvent = {
|
|
|
143
261
|
meta: Record<string, string>
|
|
144
262
|
receivedAt: number
|
|
145
263
|
}
|
|
264
|
+
type FlumeTimeEvent = {
|
|
265
|
+
source: "time"
|
|
266
|
+
type: string // "tick" by default, or whatever your message() returns
|
|
267
|
+
data: Record<string, unknown>
|
|
268
|
+
meta: Record<string, string>
|
|
269
|
+
receivedAt: number
|
|
270
|
+
}
|
|
146
271
|
|
|
147
|
-
type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent
|
|
272
|
+
type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent | FlumeTimeEvent
|
|
148
273
|
```
|
|
149
274
|
|
|
150
275
|
`meta` is flat string keys tailored per source:
|
|
151
276
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
| github | `event_type`, `reason`, `subject_type`, `repository`, `thread_id` |
|
|
277
|
+
- discord — `event_type`, `channel_id`, `guild_id`, `user_id`
|
|
278
|
+
- slack — `event_type`, `channel_id`, `user_id`, `thread_ts`, `slack_event_type`
|
|
279
|
+
- github — `event_type`, `reason`, `subject_type`, `repository`, `thread_id`
|
|
280
|
+
- time — `cron` by default, or whatever your `message()` returns
|
|
157
281
|
|
|
158
282
|
`data` is the raw parsed payload (Zod-validated at the protocol boundary).
|
|
159
283
|
|
|
160
284
|
## Observability
|
|
161
285
|
|
|
162
|
-
Flume never calls a third-party service. Every internal action is reported through the `
|
|
286
|
+
Flume never calls a third-party service. Every internal action is reported through the firehose (`onEvent` push / `stream()` pull) as `{ kind: "log", log }` items — there are no silent paths. Each `FlumeLog` is tagged with `source: "flume"`, `source: "discord"`, `source: "slack"`, `source: "github"`, `source: "time"`, or your custom source's `name`. `onError` is a convenience filter that additionally receives only the `level: "error"` subset (route it straight to Sentry).
|
|
163
287
|
|
|
164
288
|
```ts
|
|
165
289
|
type FlumeLog = {
|
|
@@ -175,18 +299,20 @@ type FlumeLog = {
|
|
|
175
299
|
|
|
176
300
|
What gets logged:
|
|
177
301
|
|
|
178
|
-
-
|
|
179
|
-
-
|
|
180
|
-
-
|
|
181
|
-
-
|
|
182
|
-
-
|
|
183
|
-
-
|
|
184
|
-
-
|
|
302
|
+
- HTTP boundary — every request URL, response status, and parsed body shape (`http.request` / `http.response` / `http.body`). Slack's `apps.connections.open` call and GitHub's poll request both emit these.
|
|
303
|
+
- WebSocket boundary — every inbound frame (`ws.recv`), every outbound frame (`ws.send` / `ws.sent`), with a 200-byte preview and total byte count. Discord op / t / s is decoded into structured `detail`.
|
|
304
|
+
- Protocol lifecycle — Discord HELLO / READY / RESUMED / RECONNECT / INVALID_SESSION / HEARTBEAT / HEARTBEAT_ACK, Slack hello / disconnect / envelope ack, GitHub bootstrap / fresh / idle.
|
|
305
|
+
- Parse failures — any Zod schema mismatch emits a `warn` with the field paths and messages. Dropped GitHub notifications carry a per-item `parse.skip` and a `parse.summary` count. Slack envelopes that don't match `FlumeSlackEnvelopeSchema` emit `envelope.parse-fail` with the incoming `type` and the issues. Discord frames with an unknown op emit `ws.unknown-op`.
|
|
306
|
+
- Reconnect — `reconnect.scheduled` (with delay in ms), `reconnect.exhausted` (with attempt count), `reconnect.reset` (on successful connect after retries), `reconnect.cancel` (on stop).
|
|
307
|
+
- Status transitions — `status` action with `previous → next`.
|
|
308
|
+
- Errors — `level: "error"` carries the `error` field; use `onError` for a pre-filtered error sink.
|
|
185
309
|
|
|
186
|
-
Route it anywhere — Sentry, Datadog, `console`, a file — your choice:
|
|
310
|
+
Route it anywhere — Sentry, Datadog, `console`, a file — your choice. Filter the log items out of the firehose:
|
|
187
311
|
|
|
188
312
|
```ts
|
|
189
|
-
|
|
313
|
+
onEvent: (item) => {
|
|
314
|
+
if (item.kind !== "log") return
|
|
315
|
+
const log = item.log
|
|
190
316
|
if (log.level === "error" && log.error) {
|
|
191
317
|
Sentry.captureException(log.error, { tags: { source: log.source, action: log.action } })
|
|
192
318
|
}
|
|
@@ -197,56 +323,67 @@ onLog: (log) => {
|
|
|
197
323
|
|
|
198
324
|
## Reconnect
|
|
199
325
|
|
|
200
|
-
`reconnect`
|
|
326
|
+
`reconnect` on `Flume` is an options object — omit it to disable reconnects entirely.
|
|
201
327
|
|
|
202
328
|
```ts
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
329
|
+
new Flume(sources, {
|
|
330
|
+
onEvent: (item) => { ... },
|
|
331
|
+
reconnect: {
|
|
332
|
+
maxAttempts: 10,
|
|
333
|
+
baseDelay: 1000, // first backoff
|
|
334
|
+
maxDelay: 30000, // backoff cap
|
|
335
|
+
},
|
|
336
|
+
})
|
|
208
337
|
```
|
|
209
338
|
|
|
210
|
-
Exponential backoff with jitter. Discord resumes the session when possible — the session id and resume URL are carried across reconnects via `FlumeDiscordGatewaySession`.
|
|
339
|
+
Exponential backoff with jitter. Discord resumes the session when possible — the session id and resume URL are carried across reconnects via `FlumeDiscordGatewaySession`. GitHub polling doesn't need reconnect (it's stateless polling); the option is wired through but ignored.
|
|
211
340
|
|
|
212
341
|
## Status
|
|
213
342
|
|
|
343
|
+
There is no dedicated status callback. Connection-state transitions ride the firehose as `status` log entries (`action: "status"`, message `previous → next`, with `detail.from` / `detail.to` / `detail.reason`). This is deliberate: a consumer feeding the firehose to an agent gets work (events) and drops (status logs) in one stream, no third callback.
|
|
344
|
+
|
|
214
345
|
```ts
|
|
215
|
-
|
|
346
|
+
onEvent: (item) => {
|
|
347
|
+
if (item.kind === "log" && item.log.action === "status") {
|
|
348
|
+
console.log(`${item.log.source}: ${item.log.message}`) // "github: connected → reconnecting (HTTP 500)"
|
|
349
|
+
}
|
|
350
|
+
}
|
|
216
351
|
```
|
|
217
352
|
|
|
218
|
-
GitHub populates `
|
|
353
|
+
The live status of each source is also readable as a snapshot via `running.statuses()` and `closed.statuses()` (`{ source, status }[]`). GitHub populates the transition `reason` with the failure cause (e.g. `"HTTP 500"`, `"network error"`); Discord and Slack leave it null.
|
|
219
354
|
|
|
220
355
|
## Cancellation
|
|
221
356
|
|
|
222
|
-
Pass an `AbortSignal` to `Flume`
|
|
357
|
+
Pass an `AbortSignal` to `Flume` — it propagates to every source via the auto-stop pathway.
|
|
223
358
|
|
|
224
|
-
|
|
359
|
+
```ts
|
|
225
360
|
const controller = new AbortController()
|
|
226
361
|
|
|
227
362
|
const flume = new Flume({
|
|
228
|
-
sources: [new FlumeDiscordSource({ token
|
|
363
|
+
sources: [new FlumeDiscordSource({ token })],
|
|
364
|
+
onEvent: (item) => { ... },
|
|
229
365
|
signal: controller.signal,
|
|
230
366
|
})
|
|
231
367
|
|
|
232
|
-
|
|
233
|
-
|
|
368
|
+
const running = await flume.open()
|
|
369
|
+
|
|
234
370
|
if (running instanceof Error) throw running
|
|
235
371
|
|
|
236
|
-
controller.abort() // FlumeRunning auto-transitions to
|
|
237
|
-
|
|
372
|
+
controller.abort() // FlumeRunning auto-transitions to FlumeClosed
|
|
373
|
+
```
|
|
238
374
|
|
|
239
|
-
If the signal is already aborted at `Flume.
|
|
375
|
+
If the signal is already aborted at `Flume.open()` time, `open` returns a `FlumeStartError` and no source is touched.
|
|
240
376
|
|
|
241
377
|
## Dependency injection
|
|
242
378
|
|
|
243
|
-
Every IO boundary (`fetch`, `WebSocket`, `now`, `random`, timers) lives in `FlumeRuntimeDeps`. `deps` is optional on
|
|
379
|
+
Every IO boundary (`fetch`, `WebSocket`, `now`, `random`, timers) lives in `FlumeRuntimeDeps`. `deps` is optional on `Flume` — when omitted, `createFlumeDefaultDeps()` wraps the global equivalents. Override only when you need mocks or runtime-specific shims. The same `deps` is handed to every source through its start context.
|
|
244
380
|
|
|
245
381
|
```ts
|
|
246
382
|
import { createFlumeDefaultDeps } from "@interactive-inc/flume"
|
|
247
383
|
|
|
248
|
-
new
|
|
249
|
-
token,
|
|
384
|
+
new Flume({
|
|
385
|
+
sources: [new FlumeDiscordSource({ token })],
|
|
386
|
+
onEvent: (item) => { ... },
|
|
250
387
|
deps: {
|
|
251
388
|
...createFlumeDefaultDeps(),
|
|
252
389
|
fetch: mockFetch,
|
|
@@ -257,31 +394,30 @@ new FlumeDiscordSource({
|
|
|
257
394
|
|
|
258
395
|
## Safety
|
|
259
396
|
|
|
260
|
-
-
|
|
261
|
-
-
|
|
262
|
-
-
|
|
263
|
-
-
|
|
397
|
+
- Ordering — each source has its own `FlumeSerialQueue` and per-source events are delivered FIFO. Cross-source ordering between events from different sources is undefined (no global serialization). `onEvent` invocations are awaited and run one at a time per source, so async callbacks don't race and `close()` drains in-flight events before transitioning state.
|
|
398
|
+
- Duplicate suppression — Slack envelopes are deduped by `envelope_id` (`FlumeSlackSeenCache`) to absorb ack retries. GitHub notifications are deduped by `id + updated_at` (`FlumeGitHubSeenCache`). Discord uses session resume so the Gateway does not re-emit dispatches.
|
|
399
|
+
- Partial-failure rollback — if any source fails during `Flume.open()`, the already-started sources are stopped and a `FlumeStartError` is returned with per-source detail.
|
|
400
|
+
- Idempotent close — `FlumeRunning.close()` is safe to call concurrently; the first call wins and subsequent callers receive the same `FlumeClosed` snapshot. The same guard exists at source level: a double-`close()` (e.g. via signal abort racing a manual close) does not re-invoke `disconnect()`.
|
|
264
401
|
|
|
265
402
|
## Errors
|
|
266
403
|
|
|
267
|
-
Flume does not throw on protocol/network failures. Every entry point returns `T | Error` — branch with `instanceof Error`. `Flume.
|
|
404
|
+
Flume does not throw on protocol/network failures. Every entry point returns `T | Error` — branch with `instanceof Error`. `Flume.open()` returns `FlumeRunning | FlumeStartError`; protocol-layer helpers (`FlumeDiscordGateway.connect()`, etc.) return `T | Error`:
|
|
268
405
|
|
|
269
|
-
- `FlumeStartError` — `Flume.
|
|
406
|
+
- `FlumeStartError` — `Flume.open()` refused or failed (already started, signal aborted, partial-failure rollback)
|
|
270
407
|
- `FlumeConnectionError` — WebSocket closed before ready
|
|
271
408
|
- `FlumeHttpError` — HTTP call returned an error payload (e.g. Slack `ok: false`)
|
|
272
409
|
- `FlumeParseError` — Unparseable WebSocket frame
|
|
273
410
|
|
|
274
|
-
|
|
411
|
+
Exceptions thrown from `onEvent` are caught and logged (never rethrown into the protocol loop).
|
|
275
412
|
|
|
276
|
-
The library guarantees that no exception escapes any public surface — constructors, `
|
|
413
|
+
The library guarantees that no exception escapes any public surface — constructors, `open()`, `close()`, the `onEvent` invocation path, the `stream()` iterator, the abort-signal path, and the `onError` callback all route IO and user-supplied callbacks through internal `safe*` wrappers and the generic `attempt()` helper. A misbehaving `onEvent` / `onError` will be logged and isolated rather than crashing the protocol loop.
|
|
277
414
|
|
|
278
415
|
## Supported sources
|
|
279
416
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
| GitHub | REST polling `/notifications` | personal access token |
|
|
417
|
+
- Discord — Gateway WebSocket v10 (JSON), bot token
|
|
418
|
+
- Slack — Socket Mode WebSocket, app token + bot token (both required)
|
|
419
|
+
- GitHub — REST polling `/notifications`, personal access token
|
|
420
|
+
- Time — cron-scheduled ticks, no external connection
|
|
285
421
|
|
|
286
422
|
GitHub also exposes `gh auth token` if you want to reuse the `gh` CLI's session:
|
|
287
423
|
|
|
@@ -293,8 +429,9 @@ const github = new FlumeGitHubSource({ token })
|
|
|
293
429
|
|
|
294
430
|
## Module layout
|
|
295
431
|
|
|
296
|
-
- `Flume` / `FlumeRunning` / `
|
|
297
|
-
- `
|
|
432
|
+
- `Flume` / `FlumeRunning` / `FlumeClosed` — type-state FSM merging multiple sources into one stream
|
|
433
|
+
- `FlumeSource` — abstract base class for any protocol source; extend it to plug in your own
|
|
434
|
+
- `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` / `FlumeTimeSource` — built-in sources, each extending `FlumeSource`
|
|
298
435
|
- `FlumeRuntimeDeps` — IO boundary port (`fetch`, `WebSocket`, `now`, `random`, timers); `WebSocket` is nullable for fetch-only runtimes
|
|
299
436
|
- `flumeExtractDiscordMeta` / `flumeExtractSlackMeta` / `flumeExtractGitHubMeta` — pure functions that build `FlumeEvent.meta` from each protocol's payload shape
|
|
300
437
|
- Internal protocol modules (`FlumeDiscordGateway`, `FlumeSlackSocketMode`, `FlumeGitHubPoller`, `FlumeReconnector`, `FlumeLogger`, seen caches, schemas) are not part of the public surface — depend on the high-level Sources only
|
|
@@ -310,7 +447,7 @@ bunx vp lint # lint
|
|
|
310
447
|
bunx vp fmt # format
|
|
311
448
|
```
|
|
312
449
|
|
|
313
|
-
The library itself is runtime-agnostic. The dev toolchain (build / test / lint) uses Bun + `vite-plus` + `vitest`, but the published `dist/` is plain ESM with TypeScript declarations and runs on Node 22+, Bun, Deno, Cloudflare Workers, or modern browsers. Runtimes without a global `WebSocket` (e.g. fetch-only edge functions) can still use `FlumeGitHubSource`; `FlumeDiscordSource` / `FlumeSlackSource` will return `FlumeStartError` at `
|
|
450
|
+
The library itself is runtime-agnostic. The dev toolchain (build / test / lint) uses Bun + `vite-plus` + `vitest`, but the published `dist/` is plain ESM with TypeScript declarations and runs on Node 22+, Bun, Deno, Cloudflare Workers, or modern browsers. Runtimes without a global `WebSocket` (e.g. fetch-only edge functions) can still use `FlumeGitHubSource`; `FlumeDiscordSource` / `FlumeSlackSource` will return `FlumeStartError` at `open()` time if `deps.WebSocket` is `null`.
|
|
314
451
|
|
|
315
452
|
## License
|
|
316
453
|
|
package/dist/discord.d.ts
CHANGED
|
@@ -1,25 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { r as FlumeDiscordSourceOptions, t as FlumeSource, w as FlumeSourceStartContext } from "./flume-source.js";
|
|
2
2
|
|
|
3
3
|
//#region lib/discord/discord-source.d.ts
|
|
4
|
-
declare class FlumeDiscordSource {
|
|
4
|
+
declare class FlumeDiscordSource extends FlumeSource {
|
|
5
5
|
private readonly options;
|
|
6
6
|
readonly name: "discord";
|
|
7
7
|
private gateway;
|
|
8
8
|
private reconnector;
|
|
9
|
-
private handler;
|
|
10
|
-
private readonly log;
|
|
11
|
-
private readonly deps;
|
|
12
|
-
private readonly queue;
|
|
13
|
-
private readonly signals;
|
|
14
|
-
private readonly statusEmitter;
|
|
15
|
-
private readonly onSignalAbort;
|
|
16
9
|
constructor(options: FlumeDiscordSourceOptions);
|
|
17
|
-
|
|
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;
|