@interactive-inc/flume 0.1.0 → 0.2.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,96 @@ 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!, 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 running = 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 (running instanceof Error) throw running
42
+
43
+ // later
44
+ await running.stop()
45
+ ```
46
+
47
+ ## Lifecycle (type-state FSM)
48
+
49
+ `Flume` enforces lifecycle correctness through three classes — misuse becomes a compile error.
50
+
51
+ ```
52
+ Flume ──start()──▶ FlumeRunning ──stop()──▶ FlumeStopped
53
+ (idle) (running) (terminal)
54
+ ```
55
+
56
+ - `Flume.start(handler)` returns `FlumeRunning | Error`. On partial failure (one source fails while another succeeds), the already-started sources are rolled back and an `Error` is returned with per-source detail.
57
+ - `FlumeRunning.stop()` returns a `FlumeStopped` snapshot. `stop()` is idempotent and concurrent-safe.
58
+ - `FlumeStopped` exposes only `statuses()` — a frozen snapshot of each source's final state. No `start`, no `stop`, no leaking source references.
59
+ - An `AbortSignal` on `Flume` drives an automatic transition to `FlumeStopped`.
60
+
61
+ ```ts
62
+ const running = await flume.start(handler)
63
+ if (running instanceof Error) {
64
+ console.error(running.message)
65
+ // "Flume.start: 1 source(s) failed: slack: connect refused"
66
+ return
67
+ }
68
+
69
+ running.start() // type error — `start` is not on FlumeRunning
70
+
71
+ const stopped = await running.stop()
72
+ stopped.stop() // type error
73
+ stopped.start() // type error
74
+ stopped.statuses() // [{ name: "discord", status: "disconnected" }, ...]
41
75
  ```
42
76
 
43
77
  ## Direct source usage
44
78
 
45
- Skip the `Flume` container and instantiate a source directly:
79
+ Sources work standalone — `Flume` is only needed for multi-source orchestration.
46
80
 
47
81
  ```ts
48
- import { FlumeDiscordSource, createFlumeDefaultDeps } from "@interactive-inc/flume"
82
+ import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
49
83
 
50
84
  const source = new FlumeDiscordSource({
51
85
  token: process.env.DISCORD_BOT_TOKEN!,
52
- deps: createFlumeDefaultDeps(),
53
86
  reconnect: true,
54
87
  onLog: (log) => console.log(log),
55
88
  })
56
89
 
57
- await source.start((event) => { /* ... */ })
90
+ const error = await source.start((event) => { /* ... */ })
91
+ if (error instanceof Error) throw error
58
92
  ```
59
93
 
60
94
  ## Sub-entries
61
95
 
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.
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.
63
97
 
64
- | sub-entry | exports |
65
- |-----------------------|-------------------------------------------------------------------|
66
- | `@interactive-inc/flume/discord` | `FlumeDiscordSource` |
67
- | `@interactive-inc/flume/slack` | `FlumeSlackSource` |
68
- | `@interactive-inc/flume/github` | `FlumeGitHubSource` |
98
+ | sub-entry | exports |
99
+ |------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
100
+ | `@interactive-inc/flume` | `Flume`, `FlumeRunning`, `FlumeStopped`, `FlumeLogger`, `FlumeReconnector`, `scheduleFlumeReconnect`, `createFlumeDefaultDeps`, errors, types |
101
+ | `@interactive-inc/flume/discord` | `FlumeDiscordSource`, `FlumeDiscordGateway`, `FlumeDiscordGatewayIntents`, `FlumeDiscordHeartbeat`, `FlumeDiscordGatewaySession`, `parseDiscordGatewayMessage`, `extractDiscordMeta`, `FlumeGatewayMessageSchema` |
102
+ | `@interactive-inc/flume/slack` | `FlumeSlackSource`, `FlumeSlackSocketMode`, `FlumeSlackSeenCache`, `obtainSlackUrl`, `extractSlackMeta`, `FlumeSlackEnvelopeSchema`, `FlumeSlackConnectionResponseSchema` |
103
+ | `@interactive-inc/flume/github` | `FlumeGitHubSource`, `FlumeGitHubPoller`, `FlumeGitHubSeenCache`, `extractGitHubMeta`, `FlumeGitHubNotificationSchema` |
69
104
 
70
105
  ```ts
71
- import { FlumeSlackSource } from "@interactive-inc/flume/slack"
106
+ import { Flume } from "@interactive-inc/flume"
72
107
  import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
108
+ import { FlumeSlackSource } from "@interactive-inc/flume/slack"
73
109
  import { FlumeGitHubSource } from "@interactive-inc/flume/github"
74
110
  ```
75
111
 
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
112
  ## Event shape
79
113
 
80
114
  Every source emits the same `FlumeEvent`:
@@ -163,32 +197,47 @@ GitHub populates `detail` with the failure reason (e.g. `"HTTP 500"`, `"network
163
197
 
164
198
  ## Cancellation
165
199
 
166
- Pass an `AbortSignal` to any source. Aborting prevents start and triggers `stop()`:
200
+ Pass an `AbortSignal` to `Flume` (propagates to every source) or to an individual source.
167
201
 
168
202
  ```ts
169
203
  const controller = new AbortController()
170
- const flume = new Flume({ signal: controller.signal, deps: createFlumeDefaultDeps() })
171
- // ...
172
- controller.abort() // all sources stop
204
+
205
+ const flume = new Flume({
206
+ sources: [new FlumeDiscordSource({ token, signal: controller.signal })],
207
+ signal: controller.signal,
208
+ })
209
+
210
+ const running = await flume.start(handler)
211
+ if (running instanceof Error) throw running
212
+
213
+ controller.abort() // FlumeRunning auto-transitions to FlumeStopped
173
214
  ```
174
215
 
216
+ If the signal is already aborted at `Flume.start()` time, `start` returns an `Error` and no source is touched.
217
+
175
218
  ## Dependency injection
176
219
 
177
- Every IO boundary (`fetch`, `WebSocket`, `now`, `random`, timers) lives in `FlumeRuntimeDeps`. The default factory wraps the global equivalents; tests pass mocks:
220
+ 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
221
 
179
222
  ```ts
180
223
  import { createFlumeDefaultDeps } from "@interactive-inc/flume"
181
224
 
182
- const deps = {
183
- ...createFlumeDefaultDeps(),
184
- fetch: mockFetch,
185
- WebSocket: MockWebSocket,
186
- now: () => 1_000,
187
- random: () => 0.5,
188
- }
225
+ new FlumeDiscordSource({
226
+ token,
227
+ deps: {
228
+ ...createFlumeDefaultDeps(),
229
+ fetch: mockFetch,
230
+ now: () => 1_000,
231
+ },
232
+ })
189
233
  ```
190
234
 
191
- `Flume` accepts `Partial<FlumeRuntimeDeps>` and merges over the defaults — override only what you need.
235
+ ## Safety
236
+
237
+ - **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.
238
+ - **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.
239
+ - **Partial-failure rollback** — if any source fails during `Flume.start()`, the already-started sources are stopped and an `Error` is returned with per-source detail.
240
+ - **Idempotent stop** — `FlumeRunning.stop()` is safe to call concurrently; the first call wins and subsequent callers receive the same `FlumeStopped` snapshot.
192
241
 
193
242
  ## Errors
194
243
 
@@ -213,18 +262,21 @@ GitHub also exposes `gh auth token` if you want to reuse the `gh` CLI's session:
213
262
  ```ts
214
263
  import { execSync } from "node:child_process"
215
264
  const token = execSync("gh auth token").toString().trim()
216
- const github = flume.github({ token })
265
+ const github = new FlumeGitHubSource({ token })
217
266
  ```
218
267
 
219
268
  ## Module layout
220
269
 
221
- - `Flume` DI container; `.discord()` / `.slack()` / `.github()` build sources with shared deps
222
- - `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` — high-level sources
270
+ - `Flume` / `FlumeRunning` / `FlumeStopped` type-state FSM merging multiple sources into one stream
271
+ - `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` — high-level sources (each conforms to the structural `FlumeSource` type)
223
272
  - `FlumeDiscordGateway` / `FlumeSlackSocketMode` / `FlumeGitHubPoller` — protocol layer
224
- - `FlumeReconnector` — exponential backoff with jitter
273
+ - `FlumeDiscordGatewaySession` — immutable session value object (id / seq / resume URL) carried across Discord reconnects
274
+ - `FlumeSlackSeenCache` / `FlumeGitHubSeenCache` — per-source duplicate suppression
275
+ - `FlumeReconnector` + `scheduleFlumeReconnect` — exponential backoff with jitter + shared reconnect scheduler
225
276
  - `FlumeLogger` — structured log emitter (feeds `onLog`)
226
- - `FlumeRuntimeDeps` — IO boundary port
227
- - Zod schemas for every external boundary: `FlumeGatewayMessageSchema`, `FlumeSlackEnvelopeSchema`, `FlumeSlackConnectionResponseSchema`, `FlumeGitHubNotificationSchema`
277
+ - `FlumeRuntimeDeps` — IO boundary port (`fetch`, `WebSocket`, `now`, `random`, timers)
278
+ - `extractDiscordMeta` / `extractSlackMeta` / `extractGitHubMeta` pure functions that build `FlumeEvent.meta` from each protocol's payload shape
279
+ - Per-source Zod schemas: `FlumeGatewayMessageSchema` (discord), `FlumeSlackEnvelopeSchema` / `FlumeSlackConnectionResponseSchema` (slack), `FlumeGitHubNotificationSchema` (github)
228
280
 
229
281
  ## Development
230
282
 
@@ -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 { D as FlumeGatewayMessageSchema, c as FlumeLogHandler, o as FlumeHandler, p as FlumeRuntimeDeps, r as FlumeGatewayMessage, t as FlumeDiscordSourceOptions, x as FlumeStatus } from "./types-tnOPBc1p.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<void | Error>;
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 };