@interactive-inc/flume 0.2.0 → 0.4.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
@@ -1,6 +1,6 @@
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 18+, Bun, Deno, Cloudflare Workers, or any environment with global `fetch` and `WebSocket`.
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 ─┐
@@ -29,7 +29,12 @@ const onLog = (log) => console.log(`[${log.level}] ${log.source}/${log.action}:
29
29
  const flume = new Flume({
30
30
  sources: [
31
31
  new FlumeDiscordSource({ token: process.env.DISCORD_BOT_TOKEN!, onLog, reconnect: true }),
32
- new FlumeSlackSource({ appToken: process.env.SLACK_APP_TOKEN!, onLog, reconnect: true }),
32
+ new FlumeSlackSource({
33
+ appToken: process.env.SLACK_APP_TOKEN!,
34
+ botToken: process.env.SLACK_BOT_TOKEN!,
35
+ onLog,
36
+ reconnect: true,
37
+ }),
33
38
  new FlumeGitHubSource({ token: process.env.GITHUB_TOKEN!, onLog, pollInterval: 60 }),
34
39
  ],
35
40
  })
@@ -53,10 +58,11 @@ Flume ──start()──▶ FlumeRunning ──stop()──▶ FlumeStopped
53
58
  (idle) (running) (terminal)
54
59
  ```
55
60
 
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.
61
+ - `Flume.start(handler)` 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.
57
62
  - `FlumeRunning.stop()` returns a `FlumeStopped` snapshot. `stop()` is idempotent and concurrent-safe.
58
63
  - `FlumeStopped` exposes only `statuses()` — a frozen snapshot of each source's final state. No `start`, no `stop`, no leaking source references.
59
64
  - An `AbortSignal` on `Flume` drives an automatic transition to `FlumeStopped`.
65
+ - `FlumeRunning.kind === "running"` and `FlumeStopped.kind === "stopped"` provide a runtime discriminator when generic code holds the union.
60
66
 
61
67
  ```ts
62
68
  const running = await flume.start(handler)
@@ -69,9 +75,9 @@ if (running instanceof Error) {
69
75
  running.start() // type error — `start` is not on FlumeRunning
70
76
 
71
77
  const stopped = await running.stop()
72
- stopped.stop() // type error
78
+ stopped.stop() // type error
73
79
  stopped.start() // type error
74
- stopped.statuses() // [{ name: "discord", status: "disconnected" }, ...]
80
+ stopped.statuses() // [{ source: "discord", status: "disconnected" }, ...]
75
81
  ```
76
82
 
77
83
  ## Direct source usage
@@ -87,7 +93,9 @@ const source = new FlumeDiscordSource({
87
93
  onLog: (log) => console.log(log),
88
94
  })
89
95
 
90
- const error = await source.start((event) => { /* ... */ })
96
+ const error = await source.start((event) => {
97
+ /* ... */
98
+ })
91
99
  if (error instanceof Error) throw error
92
100
  ```
93
101
 
@@ -95,12 +103,12 @@ if (error instanceof Error) throw error
95
103
 
96
104
  Each source has a dedicated entry — importing one does not pull the others into your bundle. The root entry never loads source-specific code.
97
105
 
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` |
106
+ | sub-entry | exports |
107
+ | -------------------------------- | -------------------------------------------------------------------------------- |
108
+ | `@interactive-inc/flume` | `Flume`, `FlumeRunning`, `FlumeStopped`, `createFlumeDefaultDeps`, errors, types |
109
+ | `@interactive-inc/flume/discord` | `FlumeDiscordSource`, `FlumeDiscordGatewayIntents`, `flumeExtractDiscordMeta` |
110
+ | `@interactive-inc/flume/slack` | `FlumeSlackSource`, `flumeExtractSlackMeta` |
111
+ | `@interactive-inc/flume/github` | `FlumeGitHubSource`, `flumeExtractGitHubMeta` |
104
112
 
105
113
  ```ts
106
114
  import { Flume } from "@interactive-inc/flume"
@@ -111,24 +119,38 @@ import { FlumeGitHubSource } from "@interactive-inc/flume/github"
111
119
 
112
120
  ## Event shape
113
121
 
114
- Every source emits the same `FlumeEvent`:
122
+ Every source emits the same `FlumeEvent` — a discriminated union keyed on `source` so `data` narrows automatically:
115
123
 
116
124
  ```ts
117
- type FlumeSourceName = "discord" | "slack" | "github"
118
-
119
- type FlumeEvent = {
120
- source: FlumeSourceName
125
+ type FlumeDiscordEvent = {
126
+ source: "discord"
121
127
  type: string
122
- data: unknown
128
+ data: Record<string, unknown>
129
+ meta: Record<string, string>
130
+ receivedAt: number
131
+ }
132
+ type FlumeSlackEvent = {
133
+ source: "slack"
134
+ type: string
135
+ data: Record<string, unknown>
136
+ meta: Record<string, string>
137
+ receivedAt: number
138
+ }
139
+ type FlumeGitHubEvent = {
140
+ source: "github"
141
+ type: "notification"
142
+ data: FlumeGitHubNotification
123
143
  meta: Record<string, string>
124
144
  receivedAt: number
125
145
  }
146
+
147
+ type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent
126
148
  ```
127
149
 
128
150
  `meta` is flat string keys tailored per source:
129
151
 
130
152
  | source | meta keys |
131
- |---------|------------------------------------------------------------------------|
153
+ | ------- | ---------------------------------------------------------------------- |
132
154
  | discord | `event_type`, `channel_id`, `guild_id`, `user_id` |
133
155
  | slack | `event_type`, `channel_id`, `user_id`, `thread_ts`, `slack_event_type` |
134
156
  | github | `event_type`, `reason`, `subject_type`, `repository`, `thread_id` |
@@ -199,7 +221,7 @@ GitHub populates `detail` with the failure reason (e.g. `"HTTP 500"`, `"network
199
221
 
200
222
  Pass an `AbortSignal` to `Flume` (propagates to every source) or to an individual source.
201
223
 
202
- ```ts
224
+ ````ts
203
225
  const controller = new AbortController()
204
226
 
205
227
  const flume = new Flume({
@@ -207,13 +229,14 @@ const flume = new Flume({
207
229
  signal: controller.signal,
208
230
  })
209
231
 
232
+ ```ts
210
233
  const running = await flume.start(handler)
211
234
  if (running instanceof Error) throw running
212
235
 
213
236
  controller.abort() // FlumeRunning auto-transitions to FlumeStopped
214
- ```
237
+ ````
215
238
 
216
- If the signal is already aborted at `Flume.start()` time, `start` returns an `Error` and no source is touched.
239
+ If the signal is already aborted at `Flume.start()` time, `start` returns a `FlumeStartError` and no source is touched.
217
240
 
218
241
  ## Dependency injection
219
242
 
@@ -234,28 +257,31 @@ new FlumeDiscordSource({
234
257
 
235
258
  ## Safety
236
259
 
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.
260
+ - **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). 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
261
  - **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.
262
+ - **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.
240
263
  - **Idempotent stop** — `FlumeRunning.stop()` is safe to call concurrently; the first call wins and subsequent callers receive the same `FlumeStopped` snapshot.
241
264
 
242
265
  ## Errors
243
266
 
244
- Flume does not throw on protocol/network failures. Connection methods return `T | Error` and you check `instanceof`:
267
+ Flume does not throw on protocol/network failures. Every entry point returns `T | Error` branch with `instanceof Error`. `Flume.start()` returns `FlumeRunning | FlumeStartError`; `Source.start()` returns `Error | null`; protocol-layer helpers (`FlumeDiscordGateway.connect()`, `obtainSlackUrl()`, …) return `T | Error`:
245
268
 
269
+ - `FlumeStartError` — `Flume.start()` / `Source.start()` refused or failed (already started, signal aborted, partial-failure rollback)
246
270
  - `FlumeConnectionError` — WebSocket closed before ready
247
271
  - `FlumeHttpError` — HTTP call returned an error payload (e.g. Slack `ok: false`)
248
272
  - `FlumeParseError` — Unparseable WebSocket frame
249
273
 
250
274
  Internal handler exceptions are caught and logged (never rethrown into the protocol loop).
251
275
 
276
+ The library guarantees that no exception escapes any public surface — constructors, `start()`, `stop()`, the handler invocation path, the abort-signal path, and the `onLog` / `onStatus` callbacks all route IO and user-supplied callbacks through internal `safe*` wrappers (`safeNow`, `safeRandom`, `safeReadText`, `safeNewWebSocket`, `safeWsSend`, `safeWsClose`, `safeInvokeCallback`, `safeAddAbortListener`, `safeRemoveAbortListener`, `safeSourceStatus`). A misbehaving `onStatus` / `onLog` / `handler` will be logged and isolated rather than crashing the protocol loop.
277
+
252
278
  ## Supported sources
253
279
 
254
- | source | transport | auth |
255
- |---------|----------------------------------|-----------------------------------------------|
256
- | Discord | Gateway WebSocket v10 (JSON) | bot token |
257
- | Slack | Socket Mode WebSocket | app token (`botToken` optional, for future) |
258
- | GitHub | REST polling `/notifications` | personal access token |
280
+ | source | transport | auth |
281
+ | ------- | ----------------------------- | ------------------------------------- |
282
+ | Discord | Gateway WebSocket v10 (JSON) | bot token |
283
+ | Slack | Socket Mode WebSocket | app token + bot token (both required) |
284
+ | GitHub | REST polling `/notifications` | personal access token |
259
285
 
260
286
  GitHub also exposes `gh auth token` if you want to reuse the `gh` CLI's session:
261
287
 
@@ -269,14 +295,9 @@ const github = new FlumeGitHubSource({ token })
269
295
 
270
296
  - `Flume` / `FlumeRunning` / `FlumeStopped` — type-state FSM merging multiple sources into one stream
271
297
  - `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` — high-level sources (each conforms to the structural `FlumeSource` type)
272
- - `FlumeDiscordGateway` / `FlumeSlackSocketMode` / `FlumeGitHubPoller` protocol layer
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
276
- - `FlumeLogger` — structured log emitter (feeds `onLog`)
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)
298
+ - `FlumeRuntimeDeps` IO boundary port (`fetch`, `WebSocket`, `now`, `random`, timers); `WebSocket` is nullable for fetch-only runtimes
299
+ - `flumeExtractDiscordMeta` / `flumeExtractSlackMeta` / `flumeExtractGitHubMeta` pure functions that build `FlumeEvent.meta` from each protocol's payload shape
300
+ - 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
280
301
 
281
302
  ## Development
282
303
 
@@ -289,7 +310,7 @@ bunx vp lint # lint
289
310
  bunx vp fmt # format
290
311
  ```
291
312
 
292
- 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 18+, Bun, Deno, Cloudflare Workers, or modern browsers.
313
+ The library itself is runtime-agnostic. The dev toolchain (build / test / lint) uses Bun + `vite-plus` + `vitest`, but the published `dist/` is plain ESM with TypeScript declarations and runs on Node 22+, Bun, Deno, Cloudflare Workers, or modern browsers. Runtimes without a global `WebSocket` (e.g. fetch-only edge functions) can still use `FlumeGitHubSource`; `FlumeDiscordSource` / `FlumeSlackSource` will return `FlumeStartError` at `start()` time if `deps.WebSocket` is `null`.
293
314
 
294
315
  ## License
295
316
 
@@ -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,6 +1,4 @@
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";
1
+ import { C as FlumeSourceStartOptions, T as FlumeStatus, c as FlumeHandler, n as FlumeDiscordSourceOptions } from "./types-D-tO-Mh2.js";
4
2
 
5
3
  //#region lib/discord/discord-source.d.ts
6
4
  declare class FlumeDiscordSource {
@@ -9,78 +7,22 @@ declare class FlumeDiscordSource {
9
7
  private gateway;
10
8
  private reconnector;
11
9
  private handler;
12
- private currentStatus;
13
10
  private readonly log;
14
11
  private readonly deps;
15
12
  private readonly queue;
13
+ private readonly signals;
14
+ private readonly statusEmitter;
15
+ private readonly onSignalAbort;
16
16
  constructor(options: FlumeDiscordSourceOptions);
17
- start(handler: FlumeHandler): Promise<void | Error>;
17
+ start(handler: FlumeHandler, options?: FlumeSourceStartOptions): Promise<Error | null>;
18
18
  stop(): Promise<void>;
19
19
  status(): FlumeStatus;
20
+ private hasWebSocket;
20
21
  private connectInternal;
21
22
  private handleDispatch;
23
+ private safeExtractMeta;
22
24
  private handleGatewayStatus;
23
25
  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
26
  }
85
27
  //#endregion
86
28
  //#region lib/discord/discord-gateway-intents.d.ts
@@ -108,24 +50,7 @@ declare const FlumeDiscordGatewayIntents: {
108
50
  readonly DirectMessagePolls: number;
109
51
  };
110
52
  //#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;
53
+ //#region lib/discord/extract-discord-meta.d.ts
54
+ declare function flumeExtractDiscordMeta(eventName: string, eventData: Record<string, unknown>): Record<string, string>;
130
55
  //#endregion
131
- export { FlumeDiscordGateway, FlumeDiscordGatewayIntents, FlumeDiscordGatewaySession, FlumeDiscordHeartbeat, FlumeDiscordSource, FlumeGatewayMessageSchema, extractDiscordMeta, parseDiscordGatewayMessage };
56
+ export { FlumeDiscordGatewayIntents, FlumeDiscordSource, flumeExtractDiscordMeta };