@interactive-inc/flume 0.6.0 → 0.9.1
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 +156 -62
- package/dist/discord.d.ts +1 -1
- package/dist/discord.js +5 -4
- package/dist/{flume-source-DuUFPhSe.d.ts → flume-source.d.ts} +52 -11
- package/dist/{flume-source-DUvt9aJt.js → flume-source.js} +7 -4
- package/dist/github.d.ts +1 -1
- package/dist/github.js +12 -7
- package/dist/index.d.ts +146 -43
- package/dist/index.js +294 -83
- package/dist/is-record.js +6 -0
- package/dist/parse-error.d.ts +9 -0
- package/dist/{safe-json-parse-CfJjt-RY.js → safe-json-parse.js} +1 -1
- package/dist/{safe-read-text-JQd_5vbd.js → safe-read-text.js} +2 -2
- package/dist/{safe-stringify-DbWQw9qe.js → safe-stringify.js} +7 -13
- package/dist/slack.d.ts +1 -1
- package/dist/slack.js +13 -8
- package/dist/time.d.ts +45 -0
- package/dist/time.js +319 -0
- package/package.json +6 -1
- /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,8 +24,8 @@ 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 flume = new Flume(
|
|
28
|
-
[
|
|
27
|
+
const flume = new Flume({
|
|
28
|
+
sources: [
|
|
29
29
|
new FlumeDiscordSource({ token: process.env.DISCORD_BOT_TOKEN! }),
|
|
30
30
|
new FlumeSlackSource({
|
|
31
31
|
appToken: process.env.SLACK_APP_TOKEN!,
|
|
@@ -33,31 +33,31 @@ const flume = new Flume(
|
|
|
33
33
|
}),
|
|
34
34
|
new FlumeGitHubSource({ token: process.env.GITHUB_TOKEN!, pollInterval: 60 }),
|
|
35
35
|
],
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
|
|
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 },
|
|
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}`)
|
|
43
40
|
},
|
|
44
|
-
)
|
|
41
|
+
onError: (log) => Sentry.captureException(log.error ?? new Error(log.message)),
|
|
42
|
+
reconnect: { maxAttempts: 10 },
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const running = await flume.open()
|
|
45
46
|
|
|
46
|
-
const running = await flume.start()
|
|
47
47
|
if (running instanceof Error) throw running
|
|
48
48
|
|
|
49
49
|
// later
|
|
50
|
-
await running.
|
|
50
|
+
await running.close()
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
`new Flume(sources, options
|
|
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
54
|
|
|
55
55
|
```ts
|
|
56
56
|
// Minimum: just open the protocols and discard everything.
|
|
57
|
-
const flume = new Flume([new FlumeDiscordSource({ token })])
|
|
57
|
+
const flume = new Flume({ sources: [new FlumeDiscordSource({ token })] })
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
const flume = new Flume([new FlumeDiscordSource({ token })],
|
|
59
|
+
// Errors-only: forward failures to Sentry, ignore the rest.
|
|
60
|
+
const flume = new Flume({ sources: [new FlumeDiscordSource({ token })], onError })
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
## Lifecycle (type-state FSM)
|
|
@@ -65,32 +65,55 @@ const flume = new Flume([new FlumeDiscordSource({ token })], { onLog })
|
|
|
65
65
|
`Flume` enforces lifecycle correctness through three classes — misuse becomes a compile error.
|
|
66
66
|
|
|
67
67
|
```
|
|
68
|
-
Flume ──
|
|
68
|
+
Flume ──open()──▶ FlumeRunning ──close()──▶ FlumeClosed
|
|
69
69
|
(idle) (running) (terminal)
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
- `Flume.
|
|
73
|
-
- `FlumeRunning.
|
|
74
|
-
- `
|
|
75
|
-
- An `AbortSignal` on `Flume` drives an automatic transition to `
|
|
76
|
-
- `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.
|
|
77
77
|
|
|
78
78
|
```ts
|
|
79
|
-
const running = await flume.
|
|
79
|
+
const running = await flume.open()
|
|
80
80
|
if (running instanceof Error) {
|
|
81
81
|
console.error(running.message)
|
|
82
|
-
// "Flume.
|
|
82
|
+
// "Flume.open: 1 source(s) failed: slack: connect refused"
|
|
83
83
|
return
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
running.
|
|
86
|
+
running.open() // type error — `open` is not on FlumeRunning
|
|
87
87
|
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
const closed = await running.close()
|
|
89
|
+
closed.close() // type error
|
|
90
|
+
closed.open() // type error
|
|
91
|
+
closed.statuses() // [{ source: "discord", status: "disconnected" }, ...]
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
## Dynamic groups
|
|
95
|
+
|
|
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.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { FlumeConfluence } from "@interactive-inc/flume"
|
|
100
|
+
|
|
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 },
|
|
107
|
+
})
|
|
108
|
+
|
|
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()
|
|
113
|
+
```
|
|
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
|
+
|
|
94
117
|
## Sub-entries
|
|
95
118
|
|
|
96
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.
|
|
@@ -100,16 +123,18 @@ import { Flume, FlumeSource } from "@interactive-inc/flume"
|
|
|
100
123
|
import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
|
|
101
124
|
import { FlumeSlackSource } from "@interactive-inc/flume/slack"
|
|
102
125
|
import { FlumeGitHubSource } from "@interactive-inc/flume/github"
|
|
126
|
+
import { FlumeTimeSource } from "@interactive-inc/flume/time"
|
|
103
127
|
```
|
|
104
128
|
|
|
105
|
-
- `@interactive-inc/flume` — `Flume`, `FlumeRunning`, `
|
|
129
|
+
- `@interactive-inc/flume` — `Flume`, `FlumeConfluence`, `FlumeRunning`, `FlumeClosed`, `FlumeSource` (abstract base for third-party sources), `createFlumeDefaultDeps`, errors, types
|
|
106
130
|
- `@interactive-inc/flume/discord` — `FlumeDiscordSource`, `FlumeDiscordGatewayIntents`, `flumeExtractDiscordMeta`
|
|
107
131
|
- `@interactive-inc/flume/slack` — `FlumeSlackSource`, `flumeExtractSlackMeta`
|
|
108
132
|
- `@interactive-inc/flume/github` — `FlumeGitHubSource`, `flumeExtractGitHubMeta`
|
|
133
|
+
- `@interactive-inc/flume/time` — `FlumeTimeSource`, `parseCron`, `flumeCronNext`
|
|
109
134
|
|
|
110
135
|
## Custom sources
|
|
111
136
|
|
|
112
|
-
Extend `FlumeSource` to plug in any protocol. The base class owns `start`/`stop`/`status`, the per-source event queue, status emission
|
|
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).
|
|
113
138
|
|
|
114
139
|
```ts
|
|
115
140
|
import { FlumeSource } from "@interactive-inc/flume"
|
|
@@ -153,7 +178,62 @@ class MyWebhookSource extends FlumeSource {
|
|
|
153
178
|
}
|
|
154
179
|
```
|
|
155
180
|
|
|
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
|
|
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
|
+
})
|
|
209
|
+
```
|
|
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.
|
|
157
237
|
|
|
158
238
|
## Event shape
|
|
159
239
|
|
|
@@ -181,8 +261,15 @@ type FlumeGitHubEvent = {
|
|
|
181
261
|
meta: Record<string, string>
|
|
182
262
|
receivedAt: number
|
|
183
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
|
+
}
|
|
184
271
|
|
|
185
|
-
type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent
|
|
272
|
+
type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent | FlumeTimeEvent
|
|
186
273
|
```
|
|
187
274
|
|
|
188
275
|
`meta` is flat string keys tailored per source:
|
|
@@ -190,12 +277,13 @@ type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent
|
|
|
190
277
|
- discord — `event_type`, `channel_id`, `guild_id`, `user_id`
|
|
191
278
|
- slack — `event_type`, `channel_id`, `user_id`, `thread_ts`, `slack_event_type`
|
|
192
279
|
- github — `event_type`, `reason`, `subject_type`, `repository`, `thread_id`
|
|
280
|
+
- time — `cron` by default, or whatever your `message()` returns
|
|
193
281
|
|
|
194
282
|
`data` is the raw parsed payload (Zod-validated at the protocol boundary).
|
|
195
283
|
|
|
196
284
|
## Observability
|
|
197
285
|
|
|
198
|
-
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).
|
|
199
287
|
|
|
200
288
|
```ts
|
|
201
289
|
type FlumeLog = {
|
|
@@ -217,12 +305,14 @@ What gets logged:
|
|
|
217
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`.
|
|
218
306
|
- Reconnect — `reconnect.scheduled` (with delay in ms), `reconnect.exhausted` (with attempt count), `reconnect.reset` (on successful connect after retries), `reconnect.cancel` (on stop).
|
|
219
307
|
- Status transitions — `status` action with `previous → next`.
|
|
220
|
-
- Errors — `level: "error"` carries the `error` field
|
|
308
|
+
- Errors — `level: "error"` carries the `error` field; use `onError` for a pre-filtered error sink.
|
|
221
309
|
|
|
222
|
-
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:
|
|
223
311
|
|
|
224
312
|
```ts
|
|
225
|
-
|
|
313
|
+
onEvent: (item) => {
|
|
314
|
+
if (item.kind !== "log") return
|
|
315
|
+
const log = item.log
|
|
226
316
|
if (log.level === "error" && log.error) {
|
|
227
317
|
Sentry.captureException(log.error, { tags: { source: log.source, action: log.action } })
|
|
228
318
|
}
|
|
@@ -237,7 +327,7 @@ onLog: (log) => {
|
|
|
237
327
|
|
|
238
328
|
```ts
|
|
239
329
|
new Flume(sources, {
|
|
240
|
-
onEvent: (
|
|
330
|
+
onEvent: (item) => { ... },
|
|
241
331
|
reconnect: {
|
|
242
332
|
maxAttempts: 10,
|
|
243
333
|
baseDelay: 1000, // first backoff
|
|
@@ -250,17 +340,17 @@ Exponential backoff with jitter. Discord resumes the session when possible — t
|
|
|
250
340
|
|
|
251
341
|
## Status
|
|
252
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
|
+
|
|
253
345
|
```ts
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
}
|
|
258
350
|
}
|
|
259
|
-
|
|
260
|
-
onStatus: (event: FlumeStatusEvent) => void
|
|
261
351
|
```
|
|
262
352
|
|
|
263
|
-
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.
|
|
264
354
|
|
|
265
355
|
## Cancellation
|
|
266
356
|
|
|
@@ -269,18 +359,20 @@ Pass an `AbortSignal` to `Flume` — it propagates to every source via the auto-
|
|
|
269
359
|
```ts
|
|
270
360
|
const controller = new AbortController()
|
|
271
361
|
|
|
272
|
-
const flume = new Flume(
|
|
273
|
-
|
|
362
|
+
const flume = new Flume({
|
|
363
|
+
sources: [new FlumeDiscordSource({ token })],
|
|
364
|
+
onEvent: (item) => { ... },
|
|
274
365
|
signal: controller.signal,
|
|
275
366
|
})
|
|
276
367
|
|
|
277
|
-
const running = await flume.
|
|
368
|
+
const running = await flume.open()
|
|
369
|
+
|
|
278
370
|
if (running instanceof Error) throw running
|
|
279
371
|
|
|
280
|
-
controller.abort() // FlumeRunning auto-transitions to
|
|
372
|
+
controller.abort() // FlumeRunning auto-transitions to FlumeClosed
|
|
281
373
|
```
|
|
282
374
|
|
|
283
|
-
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.
|
|
284
376
|
|
|
285
377
|
## Dependency injection
|
|
286
378
|
|
|
@@ -289,8 +381,9 @@ Every IO boundary (`fetch`, `WebSocket`, `now`, `random`, timers) lives in `Flum
|
|
|
289
381
|
```ts
|
|
290
382
|
import { createFlumeDefaultDeps } from "@interactive-inc/flume"
|
|
291
383
|
|
|
292
|
-
new Flume(
|
|
293
|
-
|
|
384
|
+
new Flume({
|
|
385
|
+
sources: [new FlumeDiscordSource({ token })],
|
|
386
|
+
onEvent: (item) => { ... },
|
|
294
387
|
deps: {
|
|
295
388
|
...createFlumeDefaultDeps(),
|
|
296
389
|
fetch: mockFetch,
|
|
@@ -301,29 +394,30 @@ new Flume([new FlumeDiscordSource({ token })], {
|
|
|
301
394
|
|
|
302
395
|
## Safety
|
|
303
396
|
|
|
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 `
|
|
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.
|
|
305
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.
|
|
306
|
-
- Partial-failure rollback — if any source fails during `Flume.
|
|
307
|
-
- Idempotent
|
|
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()`.
|
|
308
401
|
|
|
309
402
|
## Errors
|
|
310
403
|
|
|
311
|
-
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`:
|
|
312
405
|
|
|
313
|
-
- `FlumeStartError` — `Flume.
|
|
406
|
+
- `FlumeStartError` — `Flume.open()` refused or failed (already started, signal aborted, partial-failure rollback)
|
|
314
407
|
- `FlumeConnectionError` — WebSocket closed before ready
|
|
315
408
|
- `FlumeHttpError` — HTTP call returned an error payload (e.g. Slack `ok: false`)
|
|
316
409
|
- `FlumeParseError` — Unparseable WebSocket frame
|
|
317
410
|
|
|
318
411
|
Exceptions thrown from `onEvent` are caught and logged (never rethrown into the protocol loop).
|
|
319
412
|
|
|
320
|
-
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.
|
|
321
414
|
|
|
322
415
|
## Supported sources
|
|
323
416
|
|
|
324
417
|
- Discord — Gateway WebSocket v10 (JSON), bot token
|
|
325
418
|
- Slack — Socket Mode WebSocket, app token + bot token (both required)
|
|
326
419
|
- GitHub — REST polling `/notifications`, personal access token
|
|
420
|
+
- Time — cron-scheduled ticks, no external connection
|
|
327
421
|
|
|
328
422
|
GitHub also exposes `gh auth token` if you want to reuse the `gh` CLI's session:
|
|
329
423
|
|
|
@@ -335,9 +429,9 @@ const github = new FlumeGitHubSource({ token })
|
|
|
335
429
|
|
|
336
430
|
## Module layout
|
|
337
431
|
|
|
338
|
-
- `Flume` / `FlumeRunning` / `
|
|
432
|
+
- `Flume` / `FlumeRunning` / `FlumeClosed` — type-state FSM merging multiple sources into one stream
|
|
339
433
|
- `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`
|
|
434
|
+
- `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` / `FlumeTimeSource` — built-in sources, each extending `FlumeSource`
|
|
341
435
|
- `FlumeRuntimeDeps` — IO boundary port (`fetch`, `WebSocket`, `now`, `random`, timers); `WebSocket` is nullable for fetch-only runtimes
|
|
342
436
|
- `flumeExtractDiscordMeta` / `flumeExtractSlackMeta` / `flumeExtractGitHubMeta` — pure functions that build `FlumeEvent.meta` from each protocol's payload shape
|
|
343
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
|
|
@@ -353,7 +447,7 @@ bunx vp lint # lint
|
|
|
353
447
|
bunx vp fmt # format
|
|
354
448
|
```
|
|
355
449
|
|
|
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 `
|
|
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`.
|
|
357
451
|
|
|
358
452
|
## License
|
|
359
453
|
|
package/dist/discord.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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
4
|
declare class FlumeDiscordSource extends FlumeSource {
|
package/dist/discord.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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
|
|
2
|
-
import { t as FlumeConnectionError } from "./connection-error
|
|
3
|
-
import {
|
|
4
|
-
import { t 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.js";
|
|
2
|
+
import { t as FlumeConnectionError } from "./connection-error.js";
|
|
3
|
+
import { i as safeRandom, n as scheduleFlumeReconnect, r as FlumeReconnector, t as safeStringify } from "./safe-stringify.js";
|
|
4
|
+
import { t as isRecord } from "./is-record.js";
|
|
5
|
+
import { t as safeJsonParse } from "./safe-json-parse.js";
|
|
5
6
|
import { z } from "zod/v4";
|
|
6
7
|
//#region lib/discord/discord-gateway-session.ts
|
|
7
8
|
var FlumeDiscordGatewaySession = class FlumeDiscordGatewaySession {
|
|
@@ -74,7 +74,7 @@ type FlumeRuntimeDeps = {
|
|
|
74
74
|
setInterval(fn: () => void, ms: number): FlumeTimerHandle;
|
|
75
75
|
clearInterval(id: FlumeTimerHandle): void;
|
|
76
76
|
};
|
|
77
|
-
type FlumeSourceName = "discord" | "slack" | "github";
|
|
77
|
+
type FlumeSourceName = "discord" | "slack" | "github" | "time";
|
|
78
78
|
type FlumeDiscordEvent = {
|
|
79
79
|
source: "discord";
|
|
80
80
|
type: string;
|
|
@@ -96,15 +96,29 @@ type FlumeGitHubEvent = {
|
|
|
96
96
|
meta: Record<string, string>;
|
|
97
97
|
receivedAt: number;
|
|
98
98
|
};
|
|
99
|
-
type
|
|
99
|
+
type FlumeTimeEvent = {
|
|
100
|
+
source: "time";
|
|
101
|
+
type: string;
|
|
102
|
+
data: Record<string, unknown>;
|
|
103
|
+
meta: Record<string, string>;
|
|
104
|
+
receivedAt: number;
|
|
105
|
+
};
|
|
106
|
+
type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent | FlumeTimeEvent;
|
|
100
107
|
type FlumeEventHandler = (event: FlumeEvent) => void | Promise<void>;
|
|
101
|
-
type
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
type FlumeStreamItem = {
|
|
109
|
+
kind: "event";
|
|
110
|
+
event: FlumeEvent;
|
|
111
|
+
} | {
|
|
112
|
+
kind: "log";
|
|
113
|
+
log: FlumeLog;
|
|
114
|
+
};
|
|
115
|
+
type FlumeStreamHandler = (item: FlumeStreamItem) => void;
|
|
116
|
+
type FlumeStreamOverflow = "drop-oldest" | "drop-newest";
|
|
117
|
+
type FlumeStreamOptions = {
|
|
118
|
+
/** バッファ上限 (既定 1000)。consumer が遅れて溢れたら onOverflow に従う */buffer?: number; /** バッファ溢れ時の方針 (既定 "drop-oldest") */
|
|
119
|
+
onOverflow?: FlumeStreamOverflow;
|
|
106
120
|
};
|
|
107
|
-
type
|
|
121
|
+
type FlumeStatus = "disconnected" | "connecting" | "connected" | "reconnecting";
|
|
108
122
|
type FlumeSourceStatus = {
|
|
109
123
|
/**
|
|
110
124
|
* 多くは `FlumeSourceName` のいずれかだが、`source.name` getter が throw する
|
|
@@ -124,6 +138,8 @@ type FlumeLog = {
|
|
|
124
138
|
timestamp: number;
|
|
125
139
|
};
|
|
126
140
|
type FlumeLogHandler = (log: FlumeLog) => void;
|
|
141
|
+
/** error レベルのログだけを受け取る (Sentry など error 専用の送信先向け) */
|
|
142
|
+
type FlumeErrorHandler = (log: FlumeLog) => void;
|
|
127
143
|
type FlumeLogInput = {
|
|
128
144
|
action: string;
|
|
129
145
|
message: string;
|
|
@@ -144,9 +160,17 @@ type FlumeSourceLocalStatusHandler = (status: FlumeStatus, detail?: string) => v
|
|
|
144
160
|
type FlumeSourceStartContext = {
|
|
145
161
|
onEvent: FlumeEventHandler;
|
|
146
162
|
log: FlumeLogger;
|
|
147
|
-
deps: FlumeRuntimeDeps;
|
|
148
|
-
onStatus
|
|
163
|
+
deps: FlumeRuntimeDeps; /** Source 内部の status 遷移ブリッジ。Flume 公開 API に status callback は無く、遷移は log に出る */
|
|
164
|
+
onStatus?: FlumeSourceLocalStatusHandler;
|
|
149
165
|
reconnect: FlumeReconnectConfig | null;
|
|
166
|
+
/**
|
|
167
|
+
* Flume.start() に渡された signal をそのまま転送する。
|
|
168
|
+
* source 実装が自前で `fetch(url, { signal })` / `setTimeout` cancel / WS close を
|
|
169
|
+
* host abort 経由で発火させたい時に使う (Flume 自身は最外殻で runClose を駆動するので
|
|
170
|
+
* source は signal を無視しても動作的には停止する — 自然な伝播パスが欲しい場合のみ)。
|
|
171
|
+
* Flume.options.signal が未設定なら省略される。
|
|
172
|
+
*/
|
|
173
|
+
signal?: AbortSignal;
|
|
150
174
|
};
|
|
151
175
|
type FlumeDiscordSourceOptions = {
|
|
152
176
|
token: string;
|
|
@@ -164,6 +188,23 @@ type FlumeGitHubSourceOptions = {
|
|
|
164
188
|
token: string;
|
|
165
189
|
pollInterval?: number;
|
|
166
190
|
};
|
|
191
|
+
type FlumeTimeTick = {
|
|
192
|
+
/** cron がマッチした壁時計時刻 (epoch ms)。setTimeout の発火実時刻ではなく予定時刻 */firedAt: number;
|
|
193
|
+
cron: string;
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* tick ごとに emit するイベントの上書き内容。全フィールド optional。
|
|
197
|
+
* 省略フィールドは既定値 (type: "tick" / data: tick 内容 / meta: { cron }) になる
|
|
198
|
+
*/
|
|
199
|
+
type FlumeTimeMessage = {
|
|
200
|
+
type?: string;
|
|
201
|
+
data?: Record<string, unknown>;
|
|
202
|
+
meta?: Record<string, string>;
|
|
203
|
+
};
|
|
204
|
+
type FlumeTimeSourceOptions = {
|
|
205
|
+
/** 5 フィールド cron 式 (minute hour day-of-month month day-of-week)。壁時計 (local time) 基準 */cron: string;
|
|
206
|
+
message?: (tick: FlumeTimeTick) => FlumeTimeMessage;
|
|
207
|
+
};
|
|
167
208
|
type FlumeGatewayMessage = z.infer<typeof FlumeGatewayMessageSchema>;
|
|
168
209
|
type FlumeSlackEnvelope = z.infer<typeof FlumeSlackEnvelopeSchema>;
|
|
169
210
|
type FlumeSlackConnectionResponse = z.infer<typeof FlumeSlackConnectionResponseSchema>;
|
|
@@ -225,4 +266,4 @@ declare abstract class FlumeSource {
|
|
|
225
266
|
protected abstract disconnect(): Promise<void> | void;
|
|
226
267
|
}
|
|
227
268
|
//#endregion
|
|
228
|
-
export {
|
|
269
|
+
export { FlumeStreamOverflow as A, FlumeSourceName as C, FlumeStreamHandler as D, FlumeStatus as E, FlumeTimerHandle as F, FlumeLogger as I, FlumeTimeMessage as M, FlumeTimeSourceOptions as N, FlumeStreamItem as O, FlumeTimeTick as P, FlumeSourceLocalStatusHandler as S, FlumeSourceStatus as T, FlumeRuntimeDeps as _, FlumeEvent as a, FlumeSlackEvent as b, FlumeGitHubEvent as c, FlumeLog as d, FlumeLogHandler as f, FlumeReconnectOptions as g, FlumeReconnectConfig as h, FlumeErrorHandler as i, FlumeTimeEvent as j, FlumeStreamOptions as k, FlumeGitHubNotification as l, FlumeLogLevel as m, FlumeDiscordEvent as n, FlumeEventHandler as o, FlumeLogInput as p, FlumeDiscordSourceOptions as r, FlumeGatewayMessage as s, FlumeSource as t, FlumeGitHubSourceOptions as u, FlumeSlackConnectionResponse as v, FlumeSourceStartContext as w, FlumeSlackSourceOptions as x, FlumeSlackEnvelope as y };
|
|
@@ -298,10 +298,13 @@ var FlumeSource = class {
|
|
|
298
298
|
async stop() {
|
|
299
299
|
if (this.stopped) return;
|
|
300
300
|
this.stopped = true;
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
301
|
+
try {
|
|
302
|
+
await this.disconnect();
|
|
303
|
+
} finally {
|
|
304
|
+
await this.queue.drain();
|
|
305
|
+
this.statusEmitter?.set("disconnected");
|
|
306
|
+
this.ctx = null;
|
|
307
|
+
}
|
|
305
308
|
}
|
|
306
309
|
status() {
|
|
307
310
|
return this.statusEmitter?.value ?? "disconnected";
|
package/dist/github.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { l as FlumeGitHubNotification, t as FlumeSource, u as FlumeGitHubSourceOptions, w as FlumeSourceStartContext } from "./flume-source.js";
|
|
2
2
|
|
|
3
3
|
//#region lib/github/github-source.d.ts
|
|
4
4
|
declare class FlumeGitHubSource extends FlumeSource {
|
package/dist/github.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { c as safeNormalizeError, i as safeNow, l as safeErrorMessage, o as FlumeParseError, r as FlumeLogger, s as attempt, t as FlumeSource } from "./flume-source
|
|
2
|
-
import { t as FlumeHttpError } from "./http-error
|
|
3
|
-
import { t as safeJsonParse } from "./safe-json-parse
|
|
4
|
-
import { t as safeReadText } from "./safe-read-text
|
|
1
|
+
import { c as safeNormalizeError, i as safeNow, l as safeErrorMessage, o as FlumeParseError, r as FlumeLogger, s as attempt, t as FlumeSource } from "./flume-source.js";
|
|
2
|
+
import { t as FlumeHttpError } from "./http-error.js";
|
|
3
|
+
import { t as safeJsonParse } from "./safe-json-parse.js";
|
|
4
|
+
import { t as safeReadText } from "./safe-read-text.js";
|
|
5
5
|
import { z } from "zod/v4";
|
|
6
6
|
//#region lib/github/extract-github-meta.ts
|
|
7
7
|
function flumeExtractGitHubMeta(notification) {
|
|
@@ -43,8 +43,12 @@ var FlumeGitHubSeenCache = class {
|
|
|
43
43
|
}
|
|
44
44
|
trim() {
|
|
45
45
|
if (this.seen.size <= this.props.maxSize) return;
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
let removeCount = this.seen.size - this.props.maxSize;
|
|
47
|
+
for (const id of this.seen.keys()) {
|
|
48
|
+
if (removeCount <= 0) break;
|
|
49
|
+
this.seen.delete(id);
|
|
50
|
+
removeCount--;
|
|
51
|
+
}
|
|
48
52
|
}
|
|
49
53
|
get size() {
|
|
50
54
|
return this.seen.size;
|
|
@@ -104,6 +108,7 @@ var FlumeGitHubPoller = class {
|
|
|
104
108
|
const error = await this.poll();
|
|
105
109
|
if (error) return error;
|
|
106
110
|
if (this.isStoppedFlag) return null;
|
|
111
|
+
if (this.rateLimitTimer !== null) return null;
|
|
107
112
|
this.scheduleInterval();
|
|
108
113
|
return null;
|
|
109
114
|
}
|
|
@@ -128,7 +133,7 @@ var FlumeGitHubPoller = class {
|
|
|
128
133
|
message: safeErrorMessage({ error }),
|
|
129
134
|
error
|
|
130
135
|
});
|
|
131
|
-
})
|
|
136
|
+
});
|
|
132
137
|
}, this.effectiveIntervalSec * 1e3));
|
|
133
138
|
if (intervalResult instanceof Error) {
|
|
134
139
|
this.log.error({
|