@interactive-inc/flume 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,11 +4,11 @@ Unified notification listener for Discord, Slack, and GitHub. Raw WebSocket + `f
4
4
 
5
5
  ```
6
6
  Discord ─┐
7
- Slack ─┼──→ FlumeSource.start(handler) ──→ FlumeEvent
7
+ Slack ─┼──▶ Flume ──start(handler)──▶ FlumeEvent (one merged stream)
8
8
  GitHub ─┘
9
9
  ```
10
10
 
11
- Flume only **receives**. It opens the WebSocket / polls the API, parses the payload with Zod, and hands you a typed event. Sending replies is out of scope — bring your own HTTP call.
11
+ Flume only **receives**. It opens the WebSocket / polls the API, parses the payload with Zod, serializes events through a per-source queue, and hands you a typed event. Sending replies is out of scope — bring your own HTTP call.
12
12
 
13
13
  ## Install
14
14
 
@@ -19,62 +19,100 @@ npm add @interactive-inc/flume
19
19
  ## Quick start
20
20
 
21
21
  ```ts
22
- import { Flume, createFlumeDefaultDeps } from "@interactive-inc/flume"
22
+ import { Flume } from "@interactive-inc/flume"
23
+ import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
24
+ import { FlumeSlackSource } from "@interactive-inc/flume/slack"
25
+ import { FlumeGitHubSource } from "@interactive-inc/flume/github"
26
+
27
+ const onLog = (log) => console.log(`[${log.level}] ${log.source}/${log.action}: ${log.message}`)
23
28
 
24
29
  const flume = new Flume({
25
- deps: createFlumeDefaultDeps(),
26
- onLog: (log) => console.log(`[${log.level}] ${log.source}/${log.action}: ${log.message}`),
27
- onStatus: (status, detail) => console.log(`status: ${status} ${detail ?? ""}`),
28
- reconnect: { maxAttempts: 10, baseDelay: 1000, maxDelay: 30000 },
30
+ sources: [
31
+ new FlumeDiscordSource({ token: process.env.DISCORD_BOT_TOKEN!, onLog, reconnect: true }),
32
+ new FlumeSlackSource({ appToken: process.env.SLACK_APP_TOKEN!, botToken: process.env.SLACK_BOT_TOKEN!, onLog, reconnect: true }),
33
+ new FlumeGitHubSource({ token: process.env.GITHUB_TOKEN!, onLog, pollInterval: 60 }),
34
+ ],
29
35
  })
30
36
 
31
- const discord = flume.discord({ token: process.env.DISCORD_BOT_TOKEN! })
32
- const slack = flume.slack({ appToken: process.env.SLACK_APP_TOKEN! })
33
- const github = flume.github({ token: process.env.GITHUB_TOKEN!, pollInterval: 60 })
34
-
35
- await discord.start((event) => {
37
+ const result = await flume.start((event) => {
36
38
  console.log(event.source, event.type, event.meta)
37
39
  })
38
40
 
39
- await slack.start((event) => { /* ... */ })
40
- await github.start((event) => { /* ... */ })
41
+ if (!result.ok) throw result.error
42
+
43
+ const running = flume.runningState()!
44
+
45
+ // later
46
+ await running.stop()
47
+ ```
48
+
49
+ ## Lifecycle (type-state FSM)
50
+
51
+ `Flume` enforces lifecycle correctness through three classes — misuse becomes a compile error.
52
+
53
+ ```
54
+ Flume ──start()──▶ FlumeRunning ──stop()──▶ FlumeStopped
55
+ (idle) (running) (terminal)
56
+ ```
57
+
58
+ - `Flume.start(handler)` returns `FlumeStartResult` — a discriminated union `{ ok: true } | { ok: false; error: Error }`. On `ok: true`, the `FlumeRunning` instance is reachable via `flume.runningState()`. On partial failure (one source fails while another succeeds), the already-started sources are rolled back and `ok: false` is returned with per-source detail in `error.message`.
59
+ - `FlumeRunning.stop()` returns a `FlumeStopped` snapshot. `stop()` is idempotent and concurrent-safe.
60
+ - `FlumeStopped` exposes only `statuses()` — a frozen snapshot of each source's final state. No `start`, no `stop`, no leaking source references.
61
+ - An `AbortSignal` on `Flume` drives an automatic transition to `FlumeStopped`.
62
+
63
+ ```ts
64
+ const result = await flume.start(handler)
65
+ if (!result.ok) {
66
+ console.error(result.error.message)
67
+ // "Flume.start: 1 source(s) failed: slack: connect refused"
68
+ return
69
+ }
70
+
71
+ const running = flume.runningState()!
72
+
73
+ running.start() // type error — `start` is not on FlumeRunning
74
+
75
+ const stopped = await running.stop()
76
+ stopped.stop() // type error
77
+ stopped.start() // type error
78
+ stopped.statuses() // [{ name: "discord", status: "disconnected" }, ...]
41
79
  ```
42
80
 
43
81
  ## Direct source usage
44
82
 
45
- Skip the `Flume` container and instantiate a source directly:
83
+ Sources work standalone — `Flume` is only needed for multi-source orchestration.
46
84
 
47
85
  ```ts
48
- import { FlumeDiscordSource, createFlumeDefaultDeps } from "@interactive-inc/flume"
86
+ import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
49
87
 
50
88
  const source = new FlumeDiscordSource({
51
89
  token: process.env.DISCORD_BOT_TOKEN!,
52
- deps: createFlumeDefaultDeps(),
53
90
  reconnect: true,
54
91
  onLog: (log) => console.log(log),
55
92
  })
56
93
 
57
- await source.start((event) => { /* ... */ })
94
+ const result = await source.start((event) => { /* ... */ })
95
+ if (!result.ok) throw result.error
58
96
  ```
59
97
 
60
98
  ## Sub-entries
61
99
 
62
- Each source is also importable on its own. Use this to keep Slack's Socket Mode code out of a Discord-only bundle, or vice versa.
100
+ Each source has a dedicated entry importing one does not pull the others into your bundle. The root entry never loads source-specific code.
63
101
 
64
- | sub-entry | exports |
65
- |-----------------------|-------------------------------------------------------------------|
66
- | `@interactive-inc/flume/discord` | `FlumeDiscordSource` |
67
- | `@interactive-inc/flume/slack` | `FlumeSlackSource` |
68
- | `@interactive-inc/flume/github` | `FlumeGitHubSource` |
102
+ | sub-entry | exports |
103
+ |------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
104
+ | `@interactive-inc/flume` | `Flume`, `FlumeRunning`, `FlumeStopped`, `FlumeLogger`, `FlumeReconnector`, `createFlumeDefaultDeps`, errors, types |
105
+ | `@interactive-inc/flume/discord` | `FlumeDiscordSource`, `FlumeDiscordGateway`, `FlumeDiscordGatewayIntents`, `FlumeDiscordHeartbeat`, `FlumeDiscordGatewaySession`, `parseDiscordGatewayMessage`, `extractDiscordMeta`, `FlumeGatewayMessageSchema` |
106
+ | `@interactive-inc/flume/slack` | `FlumeSlackSource`, `FlumeSlackSocketMode`, `FlumeSlackSeenCache`, `obtainSlackUrl`, `extractSlackMeta`, `FlumeSlackEnvelopeSchema`, `FlumeSlackConnectionResponseSchema` |
107
+ | `@interactive-inc/flume/github` | `FlumeGitHubSource`, `FlumeGitHubPoller`, `FlumeGitHubSeenCache`, `extractGitHubMeta`, `FlumeGitHubNotificationSchema` |
69
108
 
70
109
  ```ts
71
- import { FlumeSlackSource } from "@interactive-inc/flume/slack"
110
+ import { Flume } from "@interactive-inc/flume"
72
111
  import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
112
+ import { FlumeSlackSource } from "@interactive-inc/flume/slack"
73
113
  import { FlumeGitHubSource } from "@interactive-inc/flume/github"
74
114
  ```
75
115
 
76
- The root entry exports the `Flume` container, every source class, every protocol class (`FlumeDiscordGateway`, `FlumeSlackSocketMode`, `FlumeGitHubPoller`), `FlumeReconnector`, `FlumeLogger`, `createFlumeDefaultDeps`, every Zod schema, every error class, and all public types.
77
-
78
116
  ## Event shape
79
117
 
80
118
  Every source emits the same `FlumeEvent`:
@@ -163,36 +201,54 @@ GitHub populates `detail` with the failure reason (e.g. `"HTTP 500"`, `"network
163
201
 
164
202
  ## Cancellation
165
203
 
166
- Pass an `AbortSignal` to any source. Aborting prevents start and triggers `stop()`:
204
+ Pass an `AbortSignal` to `Flume` (propagates to every source) or to an individual source.
167
205
 
168
206
  ```ts
169
207
  const controller = new AbortController()
170
- const flume = new Flume({ signal: controller.signal, deps: createFlumeDefaultDeps() })
171
- // ...
172
- controller.abort() // all sources stop
208
+
209
+ const flume = new Flume({
210
+ sources: [new FlumeDiscordSource({ token, signal: controller.signal })],
211
+ signal: controller.signal,
212
+ })
213
+
214
+ ```ts
215
+ const result = await flume.start(handler)
216
+ if (!result.ok) throw result.error
217
+
218
+ const running = flume.runningState()!
219
+
220
+ controller.abort() // FlumeRunning auto-transitions to FlumeStopped
173
221
  ```
174
222
 
223
+ If the signal is already aborted at `Flume.start()` time, `start` returns `{ ok: false, error }` and no source is touched.
224
+
175
225
  ## Dependency injection
176
226
 
177
- Every IO boundary (`fetch`, `WebSocket`, `now`, `random`, timers) lives in `FlumeRuntimeDeps`. The default factory wraps the global equivalents; tests pass mocks:
227
+ Every IO boundary (`fetch`, `WebSocket`, `now`, `random`, timers) lives in `FlumeRuntimeDeps`. `deps` is optional on every source — when omitted, `createFlumeDefaultDeps()` wraps the global equivalents. Override only when you need mocks or runtime-specific shims.
178
228
 
179
229
  ```ts
180
230
  import { createFlumeDefaultDeps } from "@interactive-inc/flume"
181
231
 
182
- const deps = {
183
- ...createFlumeDefaultDeps(),
184
- fetch: mockFetch,
185
- WebSocket: MockWebSocket,
186
- now: () => 1_000,
187
- random: () => 0.5,
188
- }
232
+ new FlumeDiscordSource({
233
+ token,
234
+ deps: {
235
+ ...createFlumeDefaultDeps(),
236
+ fetch: mockFetch,
237
+ now: () => 1_000,
238
+ },
239
+ })
189
240
  ```
190
241
 
191
- `Flume` accepts `Partial<FlumeRuntimeDeps>` and merges over the defaults — override only what you need.
242
+ ## Safety
243
+
244
+ - **Backpressure** — each source has its own `FlumeSerialQueue`. Handler invocations are awaited and run one at a time per source, so async handlers don't race and `stop()` drains in-flight events before transitioning state.
245
+ - **Duplicate suppression** — Slack envelopes are deduped by `envelope_id` (`FlumeSlackSeenCache`) to absorb ack retries. GitHub notifications are deduped by `id + updated_at` (`FlumeGitHubSeenCache`). Discord uses session resume so the Gateway does not re-emit dispatches.
246
+ - **Partial-failure rollback** — if any source fails during `Flume.start()`, the already-started sources are stopped and `{ ok: false, error }` is returned with per-source detail.
247
+ - **Idempotent stop** — `FlumeRunning.stop()` is safe to call concurrently; the first call wins and subsequent callers receive the same `FlumeStopped` snapshot.
192
248
 
193
249
  ## Errors
194
250
 
195
- Flume does not throw on protocol/network failures. Connection methods return `T | Error` and you check `instanceof`:
251
+ Flume does not throw on protocol/network failures. `Source.start()` and `Flume.start()` return `FlumeStartResult` (`{ ok: true } | { ok: false; error: Error }`) — branch on `result.ok`. Protocol-layer helpers (`FlumeDiscordGateway.connect()`, `obtainSlackUrl()`, …) return `T | Error` and you check `instanceof`:
196
252
 
197
253
  - `FlumeConnectionError` — WebSocket closed before ready
198
254
  - `FlumeHttpError` — HTTP call returned an error payload (e.g. Slack `ok: false`)
@@ -205,7 +261,7 @@ Internal handler exceptions are caught and logged (never rethrown into the proto
205
261
  | source | transport | auth |
206
262
  |---------|----------------------------------|-----------------------------------------------|
207
263
  | Discord | Gateway WebSocket v10 (JSON) | bot token |
208
- | Slack | Socket Mode WebSocket | app token (`botToken` optional, for future) |
264
+ | Slack | Socket Mode WebSocket | app token + bot token (both required) |
209
265
  | GitHub | REST polling `/notifications` | personal access token |
210
266
 
211
267
  GitHub also exposes `gh auth token` if you want to reuse the `gh` CLI's session:
@@ -213,18 +269,21 @@ GitHub also exposes `gh auth token` if you want to reuse the `gh` CLI's session:
213
269
  ```ts
214
270
  import { execSync } from "node:child_process"
215
271
  const token = execSync("gh auth token").toString().trim()
216
- const github = flume.github({ token })
272
+ const github = new FlumeGitHubSource({ token })
217
273
  ```
218
274
 
219
275
  ## Module layout
220
276
 
221
- - `Flume` DI container; `.discord()` / `.slack()` / `.github()` build sources with shared deps
222
- - `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` — high-level sources
277
+ - `Flume` / `FlumeRunning` / `FlumeStopped` type-state FSM merging multiple sources into one stream
278
+ - `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` — high-level sources (each conforms to the structural `FlumeSource` type)
223
279
  - `FlumeDiscordGateway` / `FlumeSlackSocketMode` / `FlumeGitHubPoller` — protocol layer
224
- - `FlumeReconnector` — exponential backoff with jitter
280
+ - `FlumeDiscordGatewaySession` — immutable session value object (id / seq / resume URL) carried across Discord reconnects
281
+ - `FlumeSlackSeenCache` / `FlumeGitHubSeenCache` — per-source duplicate suppression
282
+ - `FlumeReconnector` — exponential backoff with jitter (the internal `scheduleFlumeReconnect` helper is not exported; sources wire it themselves)
225
283
  - `FlumeLogger` — structured log emitter (feeds `onLog`)
226
- - `FlumeRuntimeDeps` — IO boundary port
227
- - Zod schemas for every external boundary: `FlumeGatewayMessageSchema`, `FlumeSlackEnvelopeSchema`, `FlumeSlackConnectionResponseSchema`, `FlumeGitHubNotificationSchema`
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)
228
287
 
229
288
  ## Development
230
289
 
@@ -0,0 +1,6 @@
1
+ //#region lib/errors/connection-error.d.ts
2
+ declare class FlumeConnectionError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ //#endregion
6
+ export { FlumeConnectionError as t };
package/dist/discord.d.ts CHANGED
@@ -1,16 +1,20 @@
1
- import { o as FlumeHandler, t as FlumeDiscordSourceOptions, y as FlumeStatus } from "./types-BVQSU336.js";
1
+ import { A as FlumeGatewayMessageSchema, C as FlumeStartResult, c as FlumeLogHandler, o as FlumeHandler, p as FlumeRuntimeDeps, r as FlumeGatewayMessage, t as FlumeDiscordSourceOptions, w as FlumeStatus } from "./types-Bm9uKUQz.js";
2
+ import { t as FlumeConnectionError } from "./connection-error-BOk97djj.js";
3
+ import { t as FlumeParseError } from "./parse-error-BAiCLRmk.js";
2
4
 
3
5
  //#region lib/discord/discord-source.d.ts
4
6
  declare class FlumeDiscordSource {
5
7
  private readonly options;
8
+ readonly name: "discord";
6
9
  private gateway;
7
10
  private reconnector;
8
11
  private handler;
9
12
  private currentStatus;
10
13
  private readonly log;
11
14
  private readonly deps;
15
+ private readonly queue;
12
16
  constructor(options: FlumeDiscordSourceOptions);
13
- start(handler: FlumeHandler): Promise<void>;
17
+ start(handler: FlumeHandler): Promise<FlumeStartResult>;
14
18
  stop(): Promise<void>;
15
19
  status(): FlumeStatus;
16
20
  private connectInternal;
@@ -18,7 +22,110 @@ declare class FlumeDiscordSource {
18
22
  private handleGatewayStatus;
19
23
  private scheduleReconnect;
20
24
  private setStatus;
21
- static extractMeta(eventName: string, eventData: Record<string, unknown>): Record<string, string>;
22
25
  }
23
26
  //#endregion
24
- export { FlumeDiscordSource };
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
+ }
85
+ //#endregion
86
+ //#region lib/discord/discord-gateway-intents.d.ts
87
+ declare const FlumeDiscordGatewayIntents: {
88
+ readonly Guilds: number;
89
+ readonly GuildMembers: number;
90
+ readonly GuildModeration: number;
91
+ readonly GuildExpressions: number;
92
+ readonly GuildIntegrations: number;
93
+ readonly GuildWebhooks: number;
94
+ readonly GuildInvites: number;
95
+ readonly GuildVoiceStates: number;
96
+ readonly GuildPresences: number;
97
+ readonly GuildMessages: number;
98
+ readonly GuildMessageReactions: number;
99
+ readonly GuildMessageTyping: number;
100
+ readonly DirectMessages: number;
101
+ readonly DirectMessageReactions: number;
102
+ readonly DirectMessageTyping: number;
103
+ readonly MessageContent: number;
104
+ readonly GuildScheduledEvents: number;
105
+ readonly AutoModerationConfiguration: number;
106
+ readonly AutoModerationExecution: number;
107
+ readonly GuildMessagePolls: number;
108
+ readonly DirectMessagePolls: number;
109
+ };
110
+ //#endregion
111
+ //#region lib/discord/discord-heartbeat.d.ts
112
+ type Props = {
113
+ onSend: () => void;
114
+ onZombie: () => void;
115
+ deps: Pick<FlumeRuntimeDeps, "setInterval" | "clearInterval">;
116
+ };
117
+ declare class FlumeDiscordHeartbeat {
118
+ private readonly props;
119
+ private timer;
120
+ private ackReceived;
121
+ constructor(props: Props);
122
+ start(intervalMs: number): void;
123
+ stop(): void;
124
+ ack(): void;
125
+ isRunning(): boolean;
126
+ }
127
+ //#endregion
128
+ //#region lib/discord/parse-discord-gateway-message.d.ts
129
+ declare function parseDiscordGatewayMessage(raw: string): FlumeGatewayMessage | FlumeParseError;
130
+ //#endregion
131
+ export { FlumeDiscordGateway, FlumeDiscordGatewayIntents, FlumeDiscordGatewaySession, FlumeDiscordHeartbeat, FlumeDiscordSource, FlumeGatewayMessageSchema, extractDiscordMeta, parseDiscordGatewayMessage };