@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 +165 -108
- package/dist/connection-error-HUO3PC3G.js +16 -0
- package/dist/discord.d.ts +10 -93
- package/dist/discord.js +446 -217
- package/dist/flume-source-DUvt9aJt.js +341 -0
- package/dist/flume-source-DuUFPhSe.d.ts +228 -0
- package/dist/github.d.ts +7 -54
- package/dist/github.js +354 -158
- package/dist/{http-error-BtXonO-W.js → http-error-CPSKoSie.js} +1 -1
- package/dist/index.d.ts +79 -49
- package/dist/index.js +304 -62
- package/dist/safe-json-parse-CfJjt-RY.js +11 -0
- package/dist/safe-read-text-JQd_5vbd.js +20 -0
- package/dist/safe-stringify-DbWQw9qe.js +157 -0
- package/dist/slack.d.ts +10 -70
- package/dist/slack.js +352 -173
- package/package.json +22 -18
- package/dist/connection-error-BOk97djj.d.ts +0 -6
- package/dist/http-error-K-Ym4lfK.d.ts +0 -11
- package/dist/logger-CpGB9WO_.js +0 -49
- package/dist/parse-error-BAiCLRmk.d.ts +0 -6
- package/dist/reconnector-BDoJ1xNX.js +0 -67
- package/dist/safe-fetch-30ZzOKHL.js +0 -20
- package/dist/safe-json-parse-BWlzGOLl.js +0 -41
- package/dist/serial-queue-ExmlnpzQ.js +0 -16
- package/dist/types-Bm9uKUQz.d.ts +0 -143
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
|
|
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(
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
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(
|
|
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
|
|
65
|
-
if (
|
|
66
|
-
console.error(
|
|
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()
|
|
89
|
+
stopped.stop() // type error
|
|
77
90
|
stopped.start() // type error
|
|
78
|
-
stopped.statuses() // [{
|
|
91
|
+
stopped.statuses() // [{ source: "discord", status: "disconnected" }, ...]
|
|
79
92
|
```
|
|
80
93
|
|
|
81
|
-
##
|
|
94
|
+
## Sub-entries
|
|
82
95
|
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
## Custom sources
|
|
101
111
|
|
|
102
|
-
|
|
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 {
|
|
111
|
-
import {
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
type
|
|
124
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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`
|
|
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
|
-
-
|
|
161
|
-
-
|
|
162
|
-
-
|
|
163
|
-
-
|
|
164
|
-
-
|
|
165
|
-
-
|
|
166
|
-
-
|
|
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`
|
|
236
|
+
`reconnect` on `Flume` is an options object — omit it to disable reconnects entirely.
|
|
183
237
|
|
|
184
238
|
```ts
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
272
|
+
const flume = new Flume([new FlumeDiscordSource({ token })], {
|
|
273
|
+
onEvent: (event) => { ... },
|
|
211
274
|
signal: controller.signal,
|
|
212
275
|
})
|
|
213
276
|
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
-
|
|
245
|
-
-
|
|
246
|
-
-
|
|
247
|
-
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
- `
|
|
279
|
-
- `
|
|
280
|
-
- `
|
|
281
|
-
- `
|
|
282
|
-
- `FlumeReconnector`
|
|
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
|
|
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 {
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
protected connect(ctx: FlumeSourceStartContext): Promise<Error | null>;
|
|
11
|
+
protected disconnect(): void;
|
|
12
|
+
private hasWebSocket;
|
|
20
13
|
private connectInternal;
|
|
21
|
-
private
|
|
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-
|
|
112
|
-
|
|
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 {
|
|
48
|
+
export { FlumeDiscordGatewayIntents, FlumeDiscordSource, flumeExtractDiscordMeta };
|