@interactive-inc/flume 0.3.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,18 +29,21 @@ 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!, botToken: process.env.SLACK_BOT_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
  })
36
41
 
37
- const result = await flume.start((event) => {
42
+ const running = await flume.start((event) => {
38
43
  console.log(event.source, event.type, event.meta)
39
44
  })
40
45
 
41
- if (!result.ok) throw result.error
42
-
43
- const running = flume.runningState()!
46
+ if (running instanceof Error) throw running
44
47
 
45
48
  // later
46
49
  await running.stop()
@@ -55,27 +58,26 @@ Flume ──start()──▶ FlumeRunning ──stop()──▶ FlumeStopped
55
58
  (idle) (running) (terminal)
56
59
  ```
57
60
 
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`.
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.
59
62
  - `FlumeRunning.stop()` returns a `FlumeStopped` snapshot. `stop()` is idempotent and concurrent-safe.
60
63
  - `FlumeStopped` exposes only `statuses()` — a frozen snapshot of each source's final state. No `start`, no `stop`, no leaking source references.
61
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.
62
66
 
63
67
  ```ts
64
- const result = await flume.start(handler)
65
- if (!result.ok) {
66
- console.error(result.error.message)
68
+ const running = await flume.start(handler)
69
+ if (running instanceof Error) {
70
+ console.error(running.message)
67
71
  // "Flume.start: 1 source(s) failed: slack: connect refused"
68
72
  return
69
73
  }
70
74
 
71
- const running = flume.runningState()!
72
-
73
75
  running.start() // type error — `start` is not on FlumeRunning
74
76
 
75
77
  const stopped = await running.stop()
76
- stopped.stop() // type error
78
+ stopped.stop() // type error
77
79
  stopped.start() // type error
78
- stopped.statuses() // [{ name: "discord", status: "disconnected" }, ...]
80
+ stopped.statuses() // [{ source: "discord", status: "disconnected" }, ...]
79
81
  ```
80
82
 
81
83
  ## Direct source usage
@@ -91,20 +93,22 @@ const source = new FlumeDiscordSource({
91
93
  onLog: (log) => console.log(log),
92
94
  })
93
95
 
94
- const result = await source.start((event) => { /* ... */ })
95
- if (!result.ok) throw result.error
96
+ const error = await source.start((event) => {
97
+ /* ... */
98
+ })
99
+ if (error instanceof Error) throw error
96
100
  ```
97
101
 
98
102
  ## Sub-entries
99
103
 
100
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.
101
105
 
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` |
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` |
108
112
 
109
113
  ```ts
110
114
  import { Flume } from "@interactive-inc/flume"
@@ -115,24 +119,38 @@ import { FlumeGitHubSource } from "@interactive-inc/flume/github"
115
119
 
116
120
  ## Event shape
117
121
 
118
- Every source emits the same `FlumeEvent`:
122
+ Every source emits the same `FlumeEvent` — a discriminated union keyed on `source` so `data` narrows automatically:
119
123
 
120
124
  ```ts
121
- type FlumeSourceName = "discord" | "slack" | "github"
122
-
123
- type FlumeEvent = {
124
- source: FlumeSourceName
125
+ type FlumeDiscordEvent = {
126
+ source: "discord"
127
+ type: string
128
+ data: Record<string, unknown>
129
+ meta: Record<string, string>
130
+ receivedAt: number
131
+ }
132
+ type FlumeSlackEvent = {
133
+ source: "slack"
125
134
  type: string
126
- data: unknown
135
+ data: Record<string, unknown>
127
136
  meta: Record<string, string>
128
137
  receivedAt: number
129
138
  }
139
+ type FlumeGitHubEvent = {
140
+ source: "github"
141
+ type: "notification"
142
+ data: FlumeGitHubNotification
143
+ meta: Record<string, string>
144
+ receivedAt: number
145
+ }
146
+
147
+ type FlumeEvent = FlumeDiscordEvent | FlumeSlackEvent | FlumeGitHubEvent
130
148
  ```
131
149
 
132
150
  `meta` is flat string keys tailored per source:
133
151
 
134
152
  | source | meta keys |
135
- |---------|------------------------------------------------------------------------|
153
+ | ------- | ---------------------------------------------------------------------- |
136
154
  | discord | `event_type`, `channel_id`, `guild_id`, `user_id` |
137
155
  | slack | `event_type`, `channel_id`, `user_id`, `thread_ts`, `slack_event_type` |
138
156
  | github | `event_type`, `reason`, `subject_type`, `repository`, `thread_id` |
@@ -203,7 +221,7 @@ GitHub populates `detail` with the failure reason (e.g. `"HTTP 500"`, `"network
203
221
 
204
222
  Pass an `AbortSignal` to `Flume` (propagates to every source) or to an individual source.
205
223
 
206
- ```ts
224
+ ````ts
207
225
  const controller = new AbortController()
208
226
 
209
227
  const flume = new Flume({
@@ -212,15 +230,13 @@ const flume = new Flume({
212
230
  })
213
231
 
214
232
  ```ts
215
- const result = await flume.start(handler)
216
- if (!result.ok) throw result.error
217
-
218
- const running = flume.runningState()!
233
+ const running = await flume.start(handler)
234
+ if (running instanceof Error) throw running
219
235
 
220
236
  controller.abort() // FlumeRunning auto-transitions to FlumeStopped
221
- ```
237
+ ````
222
238
 
223
- If the signal is already aborted at `Flume.start()` time, `start` returns `{ ok: false, 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.
224
240
 
225
241
  ## Dependency injection
226
242
 
@@ -241,28 +257,31 @@ new FlumeDiscordSource({
241
257
 
242
258
  ## Safety
243
259
 
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.
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.
245
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.
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.
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.
247
263
  - **Idempotent stop** — `FlumeRunning.stop()` is safe to call concurrently; the first call wins and subsequent callers receive the same `FlumeStopped` snapshot.
248
264
 
249
265
  ## Errors
250
266
 
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`:
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`:
252
268
 
269
+ - `FlumeStartError` — `Flume.start()` / `Source.start()` refused or failed (already started, signal aborted, partial-failure rollback)
253
270
  - `FlumeConnectionError` — WebSocket closed before ready
254
271
  - `FlumeHttpError` — HTTP call returned an error payload (e.g. Slack `ok: false`)
255
272
  - `FlumeParseError` — Unparseable WebSocket frame
256
273
 
257
274
  Internal handler exceptions are caught and logged (never rethrown into the protocol loop).
258
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
+
259
278
  ## Supported sources
260
279
 
261
- | source | transport | auth |
262
- |---------|----------------------------------|-----------------------------------------------|
263
- | Discord | Gateway WebSocket v10 (JSON) | bot token |
264
- | Slack | Socket Mode WebSocket | app token + bot token (both required) |
265
- | 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 |
266
285
 
267
286
  GitHub also exposes `gh auth token` if you want to reuse the `gh` CLI's session:
268
287
 
@@ -276,14 +295,9 @@ const github = new FlumeGitHubSource({ token })
276
295
 
277
296
  - `Flume` / `FlumeRunning` / `FlumeStopped` — type-state FSM merging multiple sources into one stream
278
297
  - `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` — high-level sources (each conforms to the structural `FlumeSource` type)
279
- - `FlumeDiscordGateway` / `FlumeSlackSocketMode` / `FlumeGitHubPoller` protocol layer
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)
283
- - `FlumeLogger` — structured log emitter (feeds `onLog`)
284
- - `FlumeRuntimeDeps` — IO boundary port (`fetch`, `WebSocket`, `now`, `random`, timers)
285
- - `extractDiscordMeta` / `extractSlackMeta` / `extractGitHubMeta` — pure functions that build `FlumeEvent.meta` from each protocol's payload shape
286
- - Per-source Zod schemas: `FlumeGatewayMessageSchema` (discord), `FlumeSlackEnvelopeSchema` / `FlumeSlackConnectionResponseSchema` (slack), `FlumeGitHubNotificationSchema` (github)
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
287
301
 
288
302
  ## Development
289
303
 
@@ -296,7 +310,7 @@ bunx vp lint # lint
296
310
  bunx vp fmt # format
297
311
  ```
298
312
 
299
- The library itself is runtime-agnostic. The dev toolchain (build / test / lint) uses Bun + `vite-plus` + `vitest`, but the published `dist/` is plain ESM with TypeScript declarations and runs on Node 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`.
300
314
 
301
315
  ## License
302
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 { 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";
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<FlumeStartResult>;
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 };