@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/dist/slack.js CHANGED
@@ -1,2 +1,443 @@
1
- import { t as FlumeSlackSource } from "./slack-source-CszepStG.js";
2
- export { FlumeSlackSource };
1
+ import { n as createFlumeDefaultDeps, t as FlumeLogger } from "./logger-CpGB9WO_.js";
2
+ import { a as FlumeConnectionError, i as FlumeParseError, n as FlumeReconnector, r as resolveFlumeReconnectConfig, t as scheduleFlumeReconnect } from "./schedule-reconnect-DSxZJG3h.js";
3
+ import { t as FlumeHttpError } from "./http-error-BtXonO-W.js";
4
+ import { t as FlumeSerialQueue } from "./serial-queue-ExmlnpzQ.js";
5
+ import { n as isRecord, t as safeJsonParse } from "./safe-json-parse-WCg_x1JS.js";
6
+ import { t as safeFetch } from "./safe-fetch-30ZzOKHL.js";
7
+ import { z } from "zod/v4";
8
+ //#region lib/slack/extract-slack-meta.ts
9
+ function extractSlackMeta(envelope) {
10
+ const meta = { event_type: envelope.type };
11
+ const eventPayload = isRecord(envelope.payload.event) ? envelope.payload.event : null;
12
+ if (!eventPayload) return meta;
13
+ if (typeof eventPayload.channel === "string") meta.channel_id = eventPayload.channel;
14
+ if (typeof eventPayload.user === "string") meta.user_id = eventPayload.user;
15
+ if (typeof eventPayload.thread_ts === "string") meta.thread_ts = eventPayload.thread_ts;
16
+ if (typeof eventPayload.type === "string") meta.slack_event_type = eventPayload.type;
17
+ return meta;
18
+ }
19
+ //#endregion
20
+ //#region lib/slack/slack-seen-cache.ts
21
+ /**
22
+ * Slack envelope_id の LRU 風キャッシュ。Slack は ack 失敗時に同じ envelope を再送するため、
23
+ * source レイヤで handler への重複配送を防ぐ
24
+ */
25
+ var FlumeSlackSeenCache = class {
26
+ props;
27
+ seen = /* @__PURE__ */ new Set();
28
+ constructor(props) {
29
+ this.props = props;
30
+ }
31
+ has(envelopeId) {
32
+ return this.seen.has(envelopeId);
33
+ }
34
+ add(envelopeId) {
35
+ this.seen.add(envelopeId);
36
+ }
37
+ trim() {
38
+ if (this.seen.size <= this.props.maxSize) return;
39
+ const entries = [...this.seen];
40
+ this.seen = new Set(entries.slice(entries.length - this.props.maxSize));
41
+ }
42
+ get size() {
43
+ return this.seen.size;
44
+ }
45
+ };
46
+ //#endregion
47
+ //#region lib/slack/slack-envelope-schema.ts
48
+ const FlumeSlackEnvelopeSchema = z.object({
49
+ envelope_id: z.string(),
50
+ type: z.string(),
51
+ payload: z.record(z.string(), z.unknown()),
52
+ accepts_response_payload: z.boolean().optional(),
53
+ retry_attempt: z.number().optional(),
54
+ retry_reason: z.string().optional()
55
+ });
56
+ //#endregion
57
+ //#region lib/slack/slack-connection-response-schema.ts
58
+ const FlumeSlackConnectionResponseSchema = z.object({
59
+ ok: z.boolean(),
60
+ url: z.string().optional(),
61
+ error: z.string().optional()
62
+ });
63
+ //#endregion
64
+ //#region lib/slack/obtain-slack-url.ts
65
+ async function obtainSlackUrl(props) {
66
+ const log = new FlumeLogger({
67
+ source: "slack.url",
68
+ handler: props.onLog,
69
+ deps: props.deps
70
+ });
71
+ const url = "https://slack.com/api/apps.connections.open";
72
+ log.debug({
73
+ action: "http.request",
74
+ message: `POST ${url}`
75
+ });
76
+ const response = await safeFetch({
77
+ fetch: props.deps.fetch,
78
+ url,
79
+ init: {
80
+ method: "POST",
81
+ headers: { Authorization: `Bearer ${props.appToken}` }
82
+ },
83
+ log
84
+ });
85
+ if (response instanceof Error) return new FlumeHttpError({
86
+ message: response.message,
87
+ status: 0
88
+ });
89
+ log.debug({
90
+ action: "http.response",
91
+ message: `POST ${response.status}`,
92
+ detail: {
93
+ status: response.status,
94
+ url
95
+ }
96
+ });
97
+ const raw = await response.json();
98
+ const peek = isRecord(raw) ? raw : {};
99
+ log.debug({
100
+ action: "http.body",
101
+ message: "apps.connections.open response",
102
+ detail: {
103
+ ok: peek.ok,
104
+ error: peek.error
105
+ }
106
+ });
107
+ const parsed = FlumeSlackConnectionResponseSchema.safeParse(raw);
108
+ if (!parsed.success) {
109
+ log.warn({
110
+ action: "parse.fail",
111
+ message: "apps.connections.open: invalid response shape",
112
+ detail: { issues: parsed.error.issues.map((i) => ({
113
+ path: i.path,
114
+ message: i.message
115
+ })) }
116
+ });
117
+ return new FlumeHttpError({
118
+ message: "apps.connections.open: invalid response shape",
119
+ status: response.status
120
+ });
121
+ }
122
+ if (!parsed.data.ok || !parsed.data.url) {
123
+ log.warn({
124
+ action: "api.fail",
125
+ message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}`
126
+ });
127
+ return new FlumeHttpError({
128
+ message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}`,
129
+ status: response.status
130
+ });
131
+ }
132
+ log.info({
133
+ action: "url.obtained",
134
+ message: "WSS URL obtained"
135
+ });
136
+ return parsed.data.url;
137
+ }
138
+ //#endregion
139
+ //#region lib/slack/slack-socket-mode.ts
140
+ function framePreview(raw) {
141
+ return raw.length > 200 ? `${raw.slice(0, 200)}... (${raw.length} bytes)` : raw;
142
+ }
143
+ var FlumeSlackSocketMode = class {
144
+ props;
145
+ log;
146
+ ws = null;
147
+ stopped = false;
148
+ pendingResolve = null;
149
+ pendingResolved = false;
150
+ constructor(props) {
151
+ this.props = props;
152
+ this.log = new FlumeLogger({
153
+ source: "slack.socket-mode",
154
+ handler: props.onLog,
155
+ deps: props.deps
156
+ });
157
+ }
158
+ async connect() {
159
+ this.log.info({
160
+ action: "connect.start",
161
+ message: "opening WebSocket connection"
162
+ });
163
+ const url = await obtainSlackUrl({
164
+ appToken: this.props.appToken,
165
+ onLog: this.props.onLog,
166
+ deps: this.props.deps
167
+ });
168
+ if (url instanceof FlumeHttpError) {
169
+ this.log.error({
170
+ action: "http.error",
171
+ message: url.message,
172
+ error: url
173
+ });
174
+ return url;
175
+ }
176
+ this.log.info({
177
+ action: "url.obtained",
178
+ message: "WebSocket URL obtained"
179
+ });
180
+ return this.openSocket(url);
181
+ }
182
+ disconnect() {
183
+ this.log.info({
184
+ action: "disconnect",
185
+ message: "stopping socket mode"
186
+ });
187
+ this.stopped = true;
188
+ if (this.ws) {
189
+ this.ws.close();
190
+ this.ws = null;
191
+ }
192
+ }
193
+ isConnected() {
194
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
195
+ }
196
+ openSocket(url) {
197
+ this.pendingResolved = false;
198
+ return new Promise((resolve) => {
199
+ this.pendingResolve = resolve;
200
+ const socket = new this.props.deps.WebSocket(url);
201
+ this.ws = socket;
202
+ socket.addEventListener("message", (ev) => this.onMessage(String(ev.data), socket));
203
+ socket.addEventListener("close", (ev) => this.onClose(ev));
204
+ socket.addEventListener("error", () => this.onError());
205
+ });
206
+ }
207
+ completeConnect(error) {
208
+ if (this.pendingResolved || !this.pendingResolve) return;
209
+ this.pendingResolved = true;
210
+ this.pendingResolve(error);
211
+ }
212
+ onMessage(raw, socket) {
213
+ this.log.debug({
214
+ action: "ws.recv",
215
+ message: framePreview(raw)
216
+ });
217
+ const json = safeJsonParse(raw);
218
+ if (!isRecord(json)) {
219
+ this.log.error({
220
+ action: "ws.parse-error",
221
+ message: "invalid JSON",
222
+ error: new FlumeParseError(raw.slice(0, 200))
223
+ });
224
+ return;
225
+ }
226
+ if (json.type === "hello") {
227
+ this.log.info({
228
+ action: "ws.hello",
229
+ message: "connection ready"
230
+ });
231
+ this.props.onConnected();
232
+ this.completeConnect(null);
233
+ return;
234
+ }
235
+ if (json.type === "disconnect") {
236
+ const reason = typeof json.reason === "string" ? json.reason : "unknown";
237
+ this.log.info({
238
+ action: "ws.disconnect-requested",
239
+ message: `reason=${reason}`,
240
+ detail: { reason }
241
+ });
242
+ socket.close();
243
+ return;
244
+ }
245
+ if (typeof json.envelope_id === "string") {
246
+ this.log.debug({
247
+ action: "ws.ack",
248
+ message: `envelope_id=${json.envelope_id}`
249
+ });
250
+ const ack = JSON.stringify({ envelope_id: json.envelope_id });
251
+ socket.send(ack);
252
+ this.log.debug({
253
+ action: "ws.send",
254
+ message: framePreview(ack)
255
+ });
256
+ }
257
+ const envelope = FlumeSlackEnvelopeSchema.safeParse(json);
258
+ if (envelope.success) {
259
+ this.log.debug({
260
+ action: "envelope.recv",
261
+ message: `type=${envelope.data.type} envelope_id=${envelope.data.envelope_id}`,
262
+ detail: {
263
+ type: envelope.data.type,
264
+ envelopeId: envelope.data.envelope_id
265
+ }
266
+ });
267
+ this.props.onMessage(envelope.data);
268
+ return;
269
+ }
270
+ this.log.warn({
271
+ action: "envelope.parse-fail",
272
+ message: "unrecognised envelope shape, dropping",
273
+ detail: {
274
+ type: typeof json.type === "string" ? json.type : "unknown",
275
+ issues: envelope.error.issues.map((i) => ({
276
+ path: i.path,
277
+ message: i.message
278
+ }))
279
+ }
280
+ });
281
+ }
282
+ onClose(ev) {
283
+ this.log.info({
284
+ action: "ws.close",
285
+ message: `code=${ev.code} reason=${ev.reason || "none"}`
286
+ });
287
+ this.ws = null;
288
+ this.props.onDisconnected();
289
+ this.completeConnect(new FlumeConnectionError(`WebSocket closed before hello (code=${ev.code})`));
290
+ }
291
+ onError() {
292
+ this.log.error({
293
+ action: "ws.error",
294
+ message: "WebSocket error event"
295
+ });
296
+ this.completeConnect(new FlumeConnectionError("WebSocket connection error"));
297
+ }
298
+ };
299
+ //#endregion
300
+ //#region lib/slack/slack-source.ts
301
+ const SEEN_CACHE_MAX = 1024;
302
+ var FlumeSlackSource = class {
303
+ options;
304
+ name = "slack";
305
+ socket = null;
306
+ reconnector = null;
307
+ handler = null;
308
+ currentStatus = "disconnected";
309
+ log;
310
+ deps;
311
+ queue = new FlumeSerialQueue();
312
+ seen = new FlumeSlackSeenCache({ maxSize: SEEN_CACHE_MAX });
313
+ constructor(options) {
314
+ this.options = options;
315
+ this.deps = options.deps ?? createFlumeDefaultDeps();
316
+ this.log = new FlumeLogger({
317
+ source: "slack",
318
+ handler: options.onLog,
319
+ deps: this.deps
320
+ });
321
+ const rc = resolveFlumeReconnectConfig(options.reconnect);
322
+ if (rc) this.reconnector = new FlumeReconnector({
323
+ ...rc,
324
+ deps: this.deps
325
+ });
326
+ }
327
+ async start(handler) {
328
+ if (this.options.signal?.aborted) return /* @__PURE__ */ new Error("Slack source: signal already aborted");
329
+ this.options.signal?.addEventListener("abort", () => this.stop(), { once: true });
330
+ this.handler = handler;
331
+ this.log.info({
332
+ action: "start",
333
+ message: "starting Slack source"
334
+ });
335
+ return this.connectInternal();
336
+ }
337
+ async stop() {
338
+ this.log.info({
339
+ action: "stop",
340
+ message: "stopping Slack source"
341
+ });
342
+ if (this.reconnector && !this.reconnector.aborted) this.log.debug({
343
+ action: "reconnect.cancel",
344
+ message: "aborting reconnector"
345
+ });
346
+ this.reconnector?.cancel();
347
+ this.socket?.disconnect();
348
+ this.socket = null;
349
+ this.handler = null;
350
+ await this.queue.drain();
351
+ this.setStatus("disconnected");
352
+ }
353
+ status() {
354
+ return this.currentStatus;
355
+ }
356
+ async connectInternal() {
357
+ this.setStatus("connecting");
358
+ this.socket = new FlumeSlackSocketMode({
359
+ appToken: this.options.appToken,
360
+ onLog: this.options.onLog,
361
+ deps: this.deps,
362
+ onMessage: (envelope) => this.handleMessage(envelope),
363
+ onConnected: () => {
364
+ if (this.reconnector && this.reconnector.attempt > 0) this.log.info({
365
+ action: "reconnect.reset",
366
+ message: `cleared ${this.reconnector.attempt} attempts`
367
+ });
368
+ this.reconnector?.reset();
369
+ this.setStatus("connected");
370
+ },
371
+ onDisconnected: () => {
372
+ if (!this.socket?.stopped) this.scheduleReconnect();
373
+ }
374
+ });
375
+ const error = await this.socket.connect();
376
+ if (error instanceof Error) {
377
+ this.log.error({
378
+ action: "connect.failed",
379
+ message: error.message,
380
+ error
381
+ });
382
+ if (!this.reconnector || this.reconnector.aborted) {
383
+ this.setStatus("disconnected");
384
+ return error;
385
+ }
386
+ this.scheduleReconnect();
387
+ }
388
+ }
389
+ handleMessage(envelope) {
390
+ if (this.seen.has(envelope.envelope_id)) {
391
+ this.log.debug({
392
+ action: "dedup.skip",
393
+ message: `duplicate envelope_id=${envelope.envelope_id}`,
394
+ detail: {
395
+ envelope_id: envelope.envelope_id,
396
+ retry_attempt: envelope.retry_attempt
397
+ }
398
+ });
399
+ return;
400
+ }
401
+ this.seen.add(envelope.envelope_id);
402
+ this.seen.trim();
403
+ const event = {
404
+ source: "slack",
405
+ type: envelope.type,
406
+ data: envelope.payload,
407
+ meta: extractSlackMeta(envelope),
408
+ receivedAt: this.deps.now()
409
+ };
410
+ this.queue.add(async () => {
411
+ try {
412
+ await this.handler?.(event);
413
+ } catch (err) {
414
+ this.log.error({
415
+ action: "handler.error",
416
+ message: "user handler threw",
417
+ error: err instanceof Error ? err : new Error(String(err))
418
+ });
419
+ }
420
+ });
421
+ }
422
+ scheduleReconnect() {
423
+ scheduleFlumeReconnect({
424
+ reconnector: this.reconnector,
425
+ log: this.log,
426
+ setStatus: (status) => this.setStatus(status),
427
+ retry: () => {
428
+ this.connectInternal();
429
+ }
430
+ });
431
+ }
432
+ setStatus(next) {
433
+ if (this.currentStatus === next) return;
434
+ this.log.info({
435
+ action: "status",
436
+ message: `${this.currentStatus} → ${next}`
437
+ });
438
+ this.currentStatus = next;
439
+ this.options.onStatus?.(next);
440
+ }
441
+ };
442
+ //#endregion
443
+ export { FlumeSlackConnectionResponseSchema, FlumeSlackEnvelopeSchema, FlumeSlackSeenCache, FlumeSlackSocketMode, FlumeSlackSource, extractSlackMeta, obtainSlackUrl };
@@ -1,25 +1,14 @@
1
1
  import { z } from "zod/v4";
2
2
 
3
- //#region lib/schema.d.ts
3
+ //#region lib/discord/discord-gateway-message-schema.d.ts
4
4
  declare const FlumeGatewayMessageSchema: z.ZodObject<{
5
5
  op: z.ZodNumber;
6
6
  d: z.ZodNullable<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
7
7
  s: z.ZodNullable<z.ZodNumber>;
8
8
  t: z.ZodNullable<z.ZodString>;
9
9
  }, z.core.$strip>;
10
- declare const FlumeSlackEnvelopeSchema: z.ZodObject<{
11
- envelope_id: z.ZodString;
12
- type: z.ZodString;
13
- payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
14
- accepts_response_payload: z.ZodOptional<z.ZodBoolean>;
15
- retry_attempt: z.ZodOptional<z.ZodNumber>;
16
- retry_reason: z.ZodOptional<z.ZodString>;
17
- }, z.core.$strip>;
18
- declare const FlumeSlackConnectionResponseSchema: z.ZodObject<{
19
- ok: z.ZodBoolean;
20
- url: z.ZodOptional<z.ZodString>;
21
- error: z.ZodOptional<z.ZodString>;
22
- }, z.core.$strip>;
10
+ //#endregion
11
+ //#region lib/github/github-notification-schema.d.ts
23
12
  declare const FlumeGitHubNotificationSchema: z.ZodObject<{
24
13
  id: z.ZodString;
25
14
  reason: z.ZodString;
@@ -35,6 +24,23 @@ declare const FlumeGitHubNotificationSchema: z.ZodObject<{
35
24
  }, z.core.$strip>;
36
25
  }, z.core.$strip>;
37
26
  //#endregion
27
+ //#region lib/slack/slack-connection-response-schema.d.ts
28
+ declare const FlumeSlackConnectionResponseSchema: z.ZodObject<{
29
+ ok: z.ZodBoolean;
30
+ url: z.ZodOptional<z.ZodString>;
31
+ error: z.ZodOptional<z.ZodString>;
32
+ }, z.core.$strip>;
33
+ //#endregion
34
+ //#region lib/slack/slack-envelope-schema.d.ts
35
+ declare const FlumeSlackEnvelopeSchema: z.ZodObject<{
36
+ envelope_id: z.ZodString;
37
+ type: z.ZodString;
38
+ payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
39
+ accepts_response_payload: z.ZodOptional<z.ZodBoolean>;
40
+ retry_attempt: z.ZodOptional<z.ZodNumber>;
41
+ retry_reason: z.ZodOptional<z.ZodString>;
42
+ }, z.core.$strip>;
43
+ //#endregion
38
44
  //#region lib/types.d.ts
39
45
  type FlumeTimerHandle = ReturnType<typeof setTimeout>;
40
46
  type FlumeRuntimeDeps = {
@@ -56,6 +62,16 @@ type FlumeEvent = {
56
62
  receivedAt: number;
57
63
  };
58
64
  type FlumeHandler = (event: FlumeEvent) => void | Promise<void>;
65
+ type FlumeSource = {
66
+ readonly name: FlumeSourceName;
67
+ start(handler: FlumeHandler): Promise<void | Error>;
68
+ stop(): Promise<void>;
69
+ status(): FlumeStatus;
70
+ };
71
+ type FlumeSourceStatus = {
72
+ name: FlumeSourceName;
73
+ status: FlumeStatus;
74
+ };
59
75
  type FlumeStatus = "disconnected" | "connecting" | "connected" | "reconnecting";
60
76
  type FlumeStatusHandler = (status: FlumeStatus, detail?: string) => void;
61
77
  type FlumeLogLevel = "debug" | "info" | "warn" | "error";
@@ -90,7 +106,7 @@ type FlumeSourceOptions = {
90
106
  onStatus?: FlumeStatusHandler;
91
107
  onLog?: FlumeLogHandler;
92
108
  signal?: AbortSignal;
93
- deps: FlumeRuntimeDeps;
109
+ deps?: FlumeRuntimeDeps;
94
110
  };
95
111
  type FlumeDiscordSourceOptions = FlumeSourceOptions & {
96
112
  token: string;
@@ -109,4 +125,4 @@ type FlumeSlackEnvelope = z.infer<typeof FlumeSlackEnvelopeSchema>;
109
125
  type FlumeSlackConnectionResponse = z.infer<typeof FlumeSlackConnectionResponseSchema>;
110
126
  type FlumeGitHubNotification = z.infer<typeof FlumeGitHubNotificationSchema>;
111
127
  //#endregion
112
- export { FlumeGitHubNotificationSchema as C, FlumeGatewayMessageSchema as S, FlumeSlackEnvelopeSchema as T, FlumeSourceName as _, FlumeGitHubSourceOptions as a, FlumeStatusHandler as b, FlumeLogHandler as c, FlumeReconnectConfig as d, FlumeReconnectOptions as f, FlumeSlackSourceOptions as g, FlumeSlackEnvelope as h, FlumeGitHubNotification as i, FlumeLogInput as l, FlumeSlackConnectionResponse as m, FlumeEvent as n, FlumeHandler as o, FlumeRuntimeDeps as p, FlumeGatewayMessage as r, FlumeLog as s, FlumeDiscordSourceOptions as t, FlumeLogLevel as u, FlumeSourceOptions as v, FlumeSlackConnectionResponseSchema as w, FlumeTimerHandle as x, FlumeStatus as y };
128
+ export { FlumeTimerHandle as C, FlumeGatewayMessageSchema as D, FlumeGitHubNotificationSchema as E, FlumeStatusHandler as S, FlumeSlackConnectionResponseSchema as T, FlumeSource as _, FlumeGitHubSourceOptions as a, FlumeSourceStatus as b, FlumeLogHandler as c, FlumeReconnectConfig as d, FlumeReconnectOptions as f, FlumeSlackSourceOptions as g, FlumeSlackEnvelope as h, FlumeGitHubNotification as i, FlumeLogInput as l, FlumeSlackConnectionResponse as m, FlumeEvent as n, FlumeHandler as o, FlumeRuntimeDeps as p, FlumeGatewayMessage as r, FlumeLog as s, FlumeDiscordSourceOptions as t, FlumeLogLevel as u, FlumeSourceName as v, FlumeSlackEnvelopeSchema as w, FlumeStatus as x, FlumeSourceOptions as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interactive-inc/flume",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Unified notification listener for Discord, Slack, and GitHub. Raw WebSocket + fetch + Zod. No SDK dependencies.",
5
5
  "keywords": [
6
6
  "discord",