@interactive-inc/flume 0.3.0 → 0.6.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,12 +1,12 @@
1
- import { n as createFlumeDefaultDeps, t as FlumeLogger } from "./logger-CpGB9WO_.js";
2
- import { i as FlumeConnectionError, n as resolveFlumeReconnectConfig, r as FlumeParseError, t as FlumeReconnector } from "./reconnector-BDoJ1xNX.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, r as scheduleFlumeReconnect, t as safeJsonParse } from "./safe-json-parse-BWlzGOLl.js";
6
- import { t as safeFetch } from "./safe-fetch-30ZzOKHL.js";
1
+ import { a as FlumeStartError, c as safeNormalizeError, i as safeNow, l as safeErrorMessage, o as FlumeParseError, r as FlumeLogger, s as attempt, t as FlumeSource } from "./flume-source-DUvt9aJt.js";
2
+ import { t as FlumeConnectionError } from "./connection-error-HUO3PC3G.js";
3
+ import { t as FlumeHttpError } from "./http-error-CPSKoSie.js";
4
+ import { i as FlumeReconnector, n as isRecord, r as scheduleFlumeReconnect, t as safeStringify } from "./safe-stringify-DbWQw9qe.js";
5
+ import { t as safeJsonParse } from "./safe-json-parse-CfJjt-RY.js";
6
+ import { t as safeReadText } from "./safe-read-text-JQd_5vbd.js";
7
7
  import { z } from "zod/v4";
8
8
  //#region lib/slack/extract-slack-meta.ts
9
- function extractSlackMeta(envelope) {
9
+ function flumeExtractSlackMeta(envelope) {
10
10
  const meta = { event_type: envelope.type };
11
11
  const eventPayload = isRecord(envelope.payload.event) ? envelope.payload.event : null;
12
12
  if (!eventPayload) return meta;
@@ -19,41 +19,40 @@ function extractSlackMeta(envelope) {
19
19
  //#endregion
20
20
  //#region lib/slack/slack-seen-cache.ts
21
21
  /**
22
- * Slack envelope_id LRU 風キャッシュ。Slack は ack 失敗時に同じ envelope を再送するため、
23
- * source レイヤで handler への重複配送を防ぐ
22
+ * Slack envelope_id maxSize / ttlMs で制限するキャッシュ。Slack は ack 失敗時に同じ envelope
23
+ * 再送してくるため、source 層で重複配送を防ぐ。TTL を過ぎたエントリは has() が false を返し、
24
+ * trim() で容量超過分が古い順に落とされる
24
25
  */
25
26
  var FlumeSlackSeenCache = class {
26
27
  props;
27
- seen = /* @__PURE__ */ new Set();
28
+ seen = /* @__PURE__ */ new Map();
28
29
  constructor(props) {
29
30
  this.props = props;
30
31
  }
31
32
  has(envelopeId) {
32
- return this.seen.has(envelopeId);
33
+ const timestamp = this.seen.get(envelopeId);
34
+ if (timestamp === void 0) return false;
35
+ if (safeNow({ deps: this.props.deps }) - timestamp > this.props.ttlMs) {
36
+ this.seen.delete(envelopeId);
37
+ return false;
38
+ }
39
+ return true;
33
40
  }
34
41
  add(envelopeId) {
35
- this.seen.add(envelopeId);
42
+ this.seen.set(envelopeId, safeNow({ deps: this.props.deps }));
36
43
  }
37
44
  trim() {
45
+ const cutoff = safeNow({ deps: this.props.deps }) - this.props.ttlMs;
46
+ for (const [id, timestamp] of this.seen) if (timestamp < cutoff) this.seen.delete(id);
38
47
  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));
48
+ const entries = [...this.seen.entries()];
49
+ this.seen = new Map(entries.slice(entries.length - this.props.maxSize));
41
50
  }
42
51
  get size() {
43
52
  return this.seen.size;
44
53
  }
45
54
  };
46
55
  //#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
56
  //#region lib/slack/slack-connection-response-schema.ts
58
57
  const FlumeSlackConnectionResponseSchema = z.object({
59
58
  ok: z.boolean(),
@@ -62,40 +61,65 @@ const FlumeSlackConnectionResponseSchema = z.object({
62
61
  });
63
62
  //#endregion
64
63
  //#region lib/slack/obtain-slack-url.ts
64
+ const URL_ENDPOINT = "https://slack.com/api/apps.connections.open";
65
65
  async function obtainSlackUrl(props) {
66
66
  const log = new FlumeLogger({
67
67
  source: "slack.url",
68
68
  handler: props.onLog,
69
69
  deps: props.deps
70
70
  });
71
- const url = "https://slack.com/api/apps.connections.open";
72
71
  log.debug({
73
72
  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
73
+ message: `POST ${URL_ENDPOINT}`
88
74
  });
75
+ const response = await attempt(() => props.deps.fetch(URL_ENDPOINT, {
76
+ method: "POST",
77
+ headers: { Authorization: `Bearer ${props.appToken}` },
78
+ signal: props.signal
79
+ }));
80
+ if (response instanceof Error) {
81
+ const error = safeNormalizeError({ value: response });
82
+ log.error({
83
+ action: "http.error",
84
+ message: safeErrorMessage({ error }),
85
+ error
86
+ });
87
+ return new FlumeConnectionError(`apps.connections.open transport: ${response.message}`, { cause: response });
88
+ }
89
89
  log.debug({
90
90
  action: "http.response",
91
91
  message: `POST ${response.status}`,
92
92
  detail: {
93
93
  status: response.status,
94
- url
94
+ url: URL_ENDPOINT
95
95
  }
96
96
  });
97
- const raw = await response.json();
98
- const peek = isRecord(raw) ? raw : {};
97
+ const text = await safeReadText({
98
+ response,
99
+ context: "apps.connections.open"
100
+ });
101
+ if (text instanceof FlumeHttpError) {
102
+ log.warn({
103
+ action: "http.body.read",
104
+ message: safeErrorMessage({ error: text }),
105
+ error: text
106
+ });
107
+ return text;
108
+ }
109
+ const json = safeJsonParse(text);
110
+ if (json instanceof FlumeParseError) {
111
+ log.warn({
112
+ action: "http.body.parse",
113
+ message: json.message,
114
+ error: json
115
+ });
116
+ return new FlumeHttpError({
117
+ message: `apps.connections.open: invalid JSON body`,
118
+ status: response.status,
119
+ cause: json
120
+ });
121
+ }
122
+ const peek = isRecord(json) ? json : {};
99
123
  log.debug({
100
124
  action: "http.body",
101
125
  message: "apps.connections.open response",
@@ -104,10 +128,10 @@ async function obtainSlackUrl(props) {
104
128
  error: peek.error
105
129
  }
106
130
  });
107
- const parsed = FlumeSlackConnectionResponseSchema.safeParse(raw);
131
+ const parsed = FlumeSlackConnectionResponseSchema.safeParse(json);
108
132
  if (!parsed.success) {
109
133
  log.warn({
110
- action: "parse.fail",
134
+ action: "http.body.schema",
111
135
  message: "apps.connections.open: invalid response shape",
112
136
  detail: { issues: parsed.error.issues.map((i) => ({
113
137
  path: i.path,
@@ -116,12 +140,13 @@ async function obtainSlackUrl(props) {
116
140
  });
117
141
  return new FlumeHttpError({
118
142
  message: "apps.connections.open: invalid response shape",
119
- status: response.status
143
+ status: response.status,
144
+ cause: parsed.error
120
145
  });
121
146
  }
122
147
  if (!parsed.data.ok || !parsed.data.url) {
123
148
  log.warn({
124
- action: "api.fail",
149
+ action: "slack.api.error",
125
150
  message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}`
126
151
  });
127
152
  return new FlumeHttpError({
@@ -130,21 +155,35 @@ async function obtainSlackUrl(props) {
130
155
  });
131
156
  }
132
157
  log.info({
133
- action: "url.obtained",
158
+ action: "slack.url.obtained",
134
159
  message: "WSS URL obtained"
135
160
  });
136
161
  return parsed.data.url;
137
162
  }
138
163
  //#endregion
164
+ //#region lib/slack/slack-envelope-schema.ts
165
+ const FlumeSlackEnvelopeSchema = z.object({
166
+ envelope_id: z.string(),
167
+ type: z.string(),
168
+ payload: z.record(z.string(), z.unknown()),
169
+ accepts_response_payload: z.boolean().optional(),
170
+ retry_attempt: z.number().optional(),
171
+ retry_reason: z.string().optional()
172
+ });
173
+ //#endregion
139
174
  //#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
- }
175
+ const WS_OPEN = 1;
176
+ /**
177
+ * Slack Socket Mode の最小 WebSocket 実装。`apps.connections.open` で URL を取得し
178
+ * `type: "hello"` を待ってから connected を通知する。
179
+ * IO 境界は全て `attempt` 経由で扱い、`connect()` は決して reject しない
180
+ */
143
181
  var FlumeSlackSocketMode = class {
144
182
  props;
145
183
  log;
146
184
  ws = null;
147
- stopped = false;
185
+ isStoppedFlag = false;
186
+ hasConnected = false;
148
187
  pendingResolve = null;
149
188
  pendingResolved = false;
150
189
  constructor(props) {
@@ -155,13 +194,17 @@ var FlumeSlackSocketMode = class {
155
194
  deps: props.deps
156
195
  });
157
196
  }
158
- async connect() {
197
+ get isStopped() {
198
+ return this.isStoppedFlag;
199
+ }
200
+ async connect(options) {
159
201
  this.log.info({
160
202
  action: "connect.start",
161
203
  message: "opening WebSocket connection"
162
204
  });
163
205
  const url = await obtainSlackUrl({
164
206
  appToken: this.props.appToken,
207
+ signal: options?.signal,
165
208
  onLog: this.props.onLog,
166
209
  deps: this.props.deps
167
210
  });
@@ -173,8 +216,24 @@ var FlumeSlackSocketMode = class {
173
216
  });
174
217
  return url;
175
218
  }
219
+ if (url instanceof FlumeConnectionError) {
220
+ this.log.error({
221
+ action: "url.error",
222
+ message: url.message,
223
+ error: url
224
+ });
225
+ return url;
226
+ }
227
+ if (this.isStoppedFlag) {
228
+ const error = new FlumeConnectionError("stopped before WebSocket open");
229
+ this.log.info({
230
+ action: "connect.aborted",
231
+ message: safeErrorMessage({ error })
232
+ });
233
+ return error;
234
+ }
176
235
  this.log.info({
177
- action: "url.obtained",
236
+ action: "slack.url.obtained",
178
237
  message: "WebSocket URL obtained"
179
238
  });
180
239
  return this.openSocket(url);
@@ -184,24 +243,59 @@ var FlumeSlackSocketMode = class {
184
243
  action: "disconnect",
185
244
  message: "stopping socket mode"
186
245
  });
187
- this.stopped = true;
188
- if (this.ws) {
189
- this.ws.close();
190
- this.ws = null;
191
- }
246
+ this.isStoppedFlag = true;
247
+ this.closeSocket(this.ws);
248
+ this.ws = null;
192
249
  }
193
250
  isConnected() {
194
- return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
251
+ return this.ws !== null && this.ws.readyState === WS_OPEN;
195
252
  }
196
253
  openSocket(url) {
254
+ const WS = this.props.deps.WebSocket;
255
+ if (!WS) {
256
+ const error = new FlumeConnectionError("WebSocket runtime not available");
257
+ this.log.error({
258
+ action: "ws.error",
259
+ message: safeErrorMessage({ error }),
260
+ error
261
+ });
262
+ return Promise.resolve(error);
263
+ }
197
264
  this.pendingResolved = false;
265
+ this.hasConnected = false;
198
266
  return new Promise((resolve) => {
199
267
  this.pendingResolve = resolve;
200
- const socket = new this.props.deps.WebSocket(url);
268
+ const socketResult = attempt(() => new WS(url));
269
+ if (socketResult instanceof Error) {
270
+ const error = new FlumeConnectionError(`WebSocket construction failed: ${safeErrorMessage({ error: socketResult })}`, { cause: socketResult });
271
+ this.log.error({
272
+ action: "ws.construct.error",
273
+ message: safeErrorMessage({ error }),
274
+ error
275
+ });
276
+ this.ws = null;
277
+ this.pendingResolved = true;
278
+ resolve(error);
279
+ return;
280
+ }
281
+ const socket = socketResult;
201
282
  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());
283
+ const listenerResult = attempt(() => {
284
+ socket.addEventListener("message", (ev) => this.safeOnMessage(ev, socket));
285
+ socket.addEventListener("close", (ev) => this.safeOnClose(ev));
286
+ socket.addEventListener("error", () => this.safeOnError());
287
+ });
288
+ if (listenerResult instanceof Error) {
289
+ const error = new FlumeConnectionError(`WebSocket listener registration failed: ${safeErrorMessage({ error: listenerResult })}`, { cause: listenerResult });
290
+ this.log.error({
291
+ action: "ws.listener.error",
292
+ message: safeErrorMessage({ error }),
293
+ error
294
+ });
295
+ this.ws = null;
296
+ this.pendingResolved = true;
297
+ resolve(error);
298
+ }
205
299
  });
206
300
  }
207
301
  completeConnect(error) {
@@ -209,25 +303,61 @@ var FlumeSlackSocketMode = class {
209
303
  this.pendingResolved = true;
210
304
  this.pendingResolve(error);
211
305
  }
212
- onMessage(raw, socket) {
213
- this.log.debug({
214
- action: "ws.recv",
215
- message: framePreview(raw)
306
+ safeOnMessage(ev, socket) {
307
+ const r = attempt(() => this.onMessage(String(ev.data), socket));
308
+ if (r instanceof Error) this.log.error({
309
+ action: "ws.message.threw",
310
+ message: safeErrorMessage({ error: r }),
311
+ error: r
312
+ });
313
+ }
314
+ safeOnClose(ev) {
315
+ const r = attempt(() => this.onClose(ev));
316
+ if (r instanceof Error) this.log.error({
317
+ action: "ws.close.threw",
318
+ message: safeErrorMessage({ error: r }),
319
+ error: r
216
320
  });
321
+ }
322
+ safeOnError() {
323
+ const r = attempt(() => this.onError());
324
+ if (r instanceof Error) this.log.error({
325
+ action: "ws.error.threw",
326
+ message: safeErrorMessage({ error: r }),
327
+ error: r
328
+ });
329
+ }
330
+ onMessage(raw, socket) {
331
+ if (this.isStoppedFlag) return;
217
332
  const json = safeJsonParse(raw);
333
+ if (json instanceof FlumeParseError) {
334
+ this.log.error({
335
+ action: "ws.parse.error",
336
+ message: json.message,
337
+ error: json,
338
+ detail: { length: raw.length }
339
+ });
340
+ return;
341
+ }
218
342
  if (!isRecord(json)) {
219
343
  this.log.error({
220
- action: "ws.parse-error",
221
- message: "invalid JSON",
222
- error: new FlumeParseError(raw.slice(0, 200))
344
+ action: "ws.parse.error",
345
+ message: "expected JSON object",
346
+ error: new FlumeParseError(`non-object frame (${typeof json})`)
223
347
  });
224
348
  return;
225
349
  }
350
+ this.log.debug({
351
+ action: "ws.recv",
352
+ message: `type=${typeof json.type === "string" ? json.type : "-"} length=${raw.length}`,
353
+ detail: { length: raw.length }
354
+ });
226
355
  if (json.type === "hello") {
227
356
  this.log.info({
228
- action: "ws.hello",
357
+ action: "socket.hello",
229
358
  message: "connection ready"
230
359
  });
360
+ this.hasConnected = true;
231
361
  this.props.onConnected();
232
362
  this.completeConnect(null);
233
363
  return;
@@ -235,11 +365,11 @@ var FlumeSlackSocketMode = class {
235
365
  if (json.type === "disconnect") {
236
366
  const reason = typeof json.reason === "string" ? json.reason : "unknown";
237
367
  this.log.info({
238
- action: "ws.disconnect-requested",
368
+ action: "ws.disconnect.requested",
239
369
  message: `reason=${reason}`,
240
370
  detail: { reason }
241
371
  });
242
- socket.close();
372
+ this.closeSocket(socket);
243
373
  return;
244
374
  }
245
375
  if (typeof json.envelope_id === "string") {
@@ -247,12 +377,8 @@ var FlumeSlackSocketMode = class {
247
377
  action: "ws.ack",
248
378
  message: `envelope_id=${json.envelope_id}`
249
379
  });
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
- });
380
+ const ack = this.safeSerialize({ envelope_id: json.envelope_id });
381
+ if (ack !== null) this.send(socket, ack);
256
382
  }
257
383
  const envelope = FlumeSlackEnvelopeSchema.safeParse(json);
258
384
  if (envelope.success) {
@@ -268,7 +394,7 @@ var FlumeSlackSocketMode = class {
268
394
  return;
269
395
  }
270
396
  this.log.warn({
271
- action: "envelope.parse-fail",
397
+ action: "envelope.parse.error",
272
398
  message: "unrecognised envelope shape, dropping",
273
399
  detail: {
274
400
  type: typeof json.type === "string" ? json.type : "unknown",
@@ -282,94 +408,139 @@ var FlumeSlackSocketMode = class {
282
408
  onClose(ev) {
283
409
  this.log.info({
284
410
  action: "ws.close",
285
- message: `code=${ev.code} reason=${ev.reason || "none"}`
411
+ message: `code=${ev.code} reason=${ev.reason || "none"}`,
412
+ detail: {
413
+ code: ev.code,
414
+ reason: ev.reason
415
+ }
286
416
  });
287
417
  this.ws = null;
288
- this.props.onDisconnected();
289
- this.completeConnect(new FlumeConnectionError(`WebSocket closed before hello (code=${ev.code})`));
418
+ if (this.hasConnected) this.props.onDisconnected();
419
+ if (!this.pendingResolved) {
420
+ const error = new FlumeConnectionError(`WebSocket closed before hello (code=${ev.code})`, { code: ev.code });
421
+ this.completeConnect(error);
422
+ }
290
423
  }
291
424
  onError() {
425
+ const error = new FlumeConnectionError("WebSocket connection error");
292
426
  this.log.error({
293
427
  action: "ws.error",
294
- message: "WebSocket error event"
428
+ message: safeErrorMessage({ error }),
429
+ error
430
+ });
431
+ this.completeConnect(error);
432
+ }
433
+ closeSocket(ws) {
434
+ if (ws === null) return;
435
+ const result = attempt(() => ws.close());
436
+ if (result instanceof Error) this.log.error({
437
+ action: "ws.close.error",
438
+ message: safeErrorMessage({ error: result }),
439
+ error: result
295
440
  });
296
- this.completeConnect(new FlumeConnectionError("WebSocket connection error"));
441
+ }
442
+ send(socket, payload) {
443
+ if (socket.readyState !== WS_OPEN) {
444
+ this.log.warn({
445
+ action: "ws.send",
446
+ message: `ws.send skipped: readyState=${socket.readyState} (not OPEN)`,
447
+ detail: { readyState: socket.readyState }
448
+ });
449
+ return;
450
+ }
451
+ const result = attempt(() => socket.send(payload));
452
+ if (result instanceof Error) this.log.error({
453
+ action: "ws.send",
454
+ message: `ws.send failed: ${safeErrorMessage({ error: result })}`,
455
+ error: result
456
+ });
457
+ }
458
+ safeSerialize(value) {
459
+ const result = safeStringify(value);
460
+ if (result instanceof Error) {
461
+ this.log.error({
462
+ action: "ws.send.serialize.error",
463
+ message: safeErrorMessage({ error: result }),
464
+ error: result
465
+ });
466
+ return null;
467
+ }
468
+ return result;
297
469
  }
298
470
  };
299
471
  //#endregion
300
472
  //#region lib/slack/slack-source.ts
301
473
  const SEEN_CACHE_MAX = 1024;
302
- var FlumeSlackSource = class {
474
+ const SEEN_CACHE_TTL_MS = 300 * 1e3;
475
+ var FlumeSlackSource = class extends FlumeSource {
303
476
  options;
304
477
  name = "slack";
305
478
  socket = null;
306
479
  reconnector = null;
307
- handler = null;
308
- currentStatus = "disconnected";
309
- log;
310
- deps;
311
- queue = new FlumeSerialQueue();
312
- seen = new FlumeSlackSeenCache({ maxSize: SEEN_CACHE_MAX });
480
+ internalController = null;
481
+ seen = null;
313
482
  constructor(options) {
483
+ super();
314
484
  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
485
  }
327
- async start(handler) {
328
- if (this.options.signal?.aborted) return {
329
- ok: false,
330
- error: /* @__PURE__ */ new Error("Slack source: signal already aborted")
331
- };
332
- this.options.signal?.addEventListener("abort", () => this.stop(), { once: true });
333
- this.handler = handler;
334
- this.log.info({
335
- action: "start",
336
- message: "starting Slack source"
486
+ async connect(ctx) {
487
+ if (!this.hasWebSocket(ctx)) return new FlumeStartError("Slack source: deps.WebSocket is null (no WebSocket runtime available)");
488
+ this.seen = new FlumeSlackSeenCache({
489
+ maxSize: SEEN_CACHE_MAX,
490
+ ttlMs: SEEN_CACHE_TTL_MS,
491
+ deps: ctx.deps
337
492
  });
338
- const result = await this.connectInternal();
339
- if (result instanceof Error) return {
340
- ok: false,
341
- error: result
342
- };
343
- return { ok: true };
344
- }
345
- async stop() {
346
- this.log.info({
347
- action: "stop",
348
- message: "stopping Slack source"
493
+ if (ctx.reconnect && !this.reconnector) this.reconnector = new FlumeReconnector({
494
+ ...ctx.reconnect,
495
+ log: ctx.log,
496
+ deps: ctx.deps
349
497
  });
350
- if (this.reconnector && !this.reconnector.aborted) this.log.debug({
498
+ const controllerResult = attempt(() => new AbortController());
499
+ if (controllerResult instanceof Error) {
500
+ const error = safeNormalizeError({ value: controllerResult });
501
+ ctx.log.error({
502
+ action: "slack.abort-controller.new.error",
503
+ message: safeErrorMessage({ error }),
504
+ error
505
+ });
506
+ this.internalController = null;
507
+ } else this.internalController = controllerResult;
508
+ return await this.connectInternal(ctx);
509
+ }
510
+ disconnect() {
511
+ const ctx = this.context;
512
+ if (ctx && this.reconnector && !this.reconnector.aborted) ctx.log.debug({
351
513
  action: "reconnect.cancel",
352
514
  message: "aborting reconnector"
353
515
  });
354
516
  this.reconnector?.cancel();
517
+ this.internalController?.abort();
355
518
  this.socket?.disconnect();
356
519
  this.socket = null;
357
- this.handler = null;
358
- await this.queue.drain();
359
- this.setStatus("disconnected");
520
+ this.internalController = null;
360
521
  }
361
- status() {
362
- return this.currentStatus;
522
+ hasWebSocket(ctx) {
523
+ const result = attempt(() => Boolean(ctx.deps.WebSocket));
524
+ if (result instanceof Error) {
525
+ const error = safeNormalizeError({ value: result });
526
+ ctx.log.error({
527
+ action: "deps.web-socket.read.error",
528
+ message: safeErrorMessage({ error }),
529
+ error
530
+ });
531
+ return false;
532
+ }
533
+ return result;
363
534
  }
364
- async connectInternal() {
535
+ async connectInternal(ctx) {
365
536
  this.setStatus("connecting");
366
537
  this.socket = new FlumeSlackSocketMode({
367
538
  appToken: this.options.appToken,
368
- onLog: this.options.onLog,
369
- deps: this.deps,
370
- onMessage: (envelope) => this.handleMessage(envelope),
539
+ onLog: ctx.log.handler,
540
+ deps: ctx.deps,
541
+ onMessage: (envelope) => this.handleMessage(ctx, envelope),
371
542
  onConnected: () => {
372
- if (this.reconnector && this.reconnector.attempt > 0) this.log.info({
543
+ if (this.reconnector && this.reconnector.attempt > 0) ctx.log.info({
373
544
  action: "reconnect.reset",
374
545
  message: `cleared ${this.reconnector.attempt} attempts`
375
546
  });
@@ -377,27 +548,33 @@ var FlumeSlackSource = class {
377
548
  this.setStatus("connected");
378
549
  },
379
550
  onDisconnected: () => {
380
- if (!this.socket?.stopped) this.scheduleReconnect();
551
+ if (this.socket?.isStopped) {
552
+ this.setStatus("disconnected");
553
+ return;
554
+ }
555
+ this.scheduleReconnect(ctx);
381
556
  }
382
557
  });
383
- const error = await this.socket.connect();
558
+ const error = await this.socket.connect({ signal: this.internalController?.signal });
384
559
  if (error instanceof Error) {
385
- this.log.error({
560
+ ctx.log.error({
386
561
  action: "connect.failed",
387
- message: error.message,
562
+ message: safeErrorMessage({ error }),
388
563
  error
389
564
  });
390
- if (!this.reconnector || this.reconnector.aborted) {
565
+ if (this.socket.isStopped || !this.reconnector || this.reconnector.aborted) {
391
566
  this.setStatus("disconnected");
392
567
  return error;
393
568
  }
394
- this.scheduleReconnect();
569
+ this.scheduleReconnect(ctx);
395
570
  }
396
571
  return null;
397
572
  }
398
- handleMessage(envelope) {
399
- if (this.seen.has(envelope.envelope_id)) {
400
- this.log.debug({
573
+ handleMessage(ctx, envelope) {
574
+ const seen = this.seen;
575
+ if (!seen) return;
576
+ if (seen.has(envelope.envelope_id)) {
577
+ ctx.log.debug({
401
578
  action: "dedup.skip",
402
579
  message: `duplicate envelope_id=${envelope.envelope_id}`,
403
580
  detail: {
@@ -407,46 +584,48 @@ var FlumeSlackSource = class {
407
584
  });
408
585
  return;
409
586
  }
410
- this.seen.add(envelope.envelope_id);
411
- this.seen.trim();
412
- const event = {
587
+ seen.add(envelope.envelope_id);
588
+ seen.trim();
589
+ this.emit({
413
590
  source: "slack",
414
591
  type: envelope.type,
415
592
  data: envelope.payload,
416
- meta: extractSlackMeta(envelope),
417
- receivedAt: this.deps.now()
418
- };
419
- this.queue.add(async () => {
420
- try {
421
- await this.handler?.(event);
422
- } catch (err) {
423
- this.log.error({
424
- action: "handler.error",
425
- message: "user handler threw",
426
- error: err instanceof Error ? err : new Error(String(err))
427
- });
428
- }
593
+ meta: this.safeExtractMeta(ctx, envelope),
594
+ receivedAt: safeNow({ deps: ctx.deps })
429
595
  });
430
596
  }
431
- scheduleReconnect() {
597
+ safeExtractMeta(ctx, envelope) {
598
+ const result = attempt(() => flumeExtractSlackMeta(envelope));
599
+ if (result instanceof Error) {
600
+ const error = safeNormalizeError({ value: result });
601
+ ctx.log.warn({
602
+ action: "meta.extract.error",
603
+ message: safeErrorMessage({ error }),
604
+ error,
605
+ detail: { envelopeType: envelope.type }
606
+ });
607
+ return { event_type: envelope.type };
608
+ }
609
+ return result;
610
+ }
611
+ scheduleReconnect(ctx) {
432
612
  scheduleFlumeReconnect({
433
613
  reconnector: this.reconnector,
434
- log: this.log,
614
+ log: ctx.log,
435
615
  setStatus: (status) => this.setStatus(status),
436
616
  retry: () => {
437
- this.connectInternal();
617
+ this.connectInternal(ctx).catch((err) => {
618
+ const error = safeNormalizeError({ value: err });
619
+ ctx.log.error({
620
+ action: "reconnect.unhandled",
621
+ message: safeErrorMessage({ error }),
622
+ error
623
+ });
624
+ this.setStatus("disconnected");
625
+ });
438
626
  }
439
627
  });
440
628
  }
441
- setStatus(next) {
442
- if (this.currentStatus === next) return;
443
- this.log.info({
444
- action: "status",
445
- message: `${this.currentStatus} → ${next}`
446
- });
447
- this.currentStatus = next;
448
- this.options.onStatus?.(next);
449
- }
450
629
  };
451
630
  //#endregion
452
- export { FlumeSlackConnectionResponseSchema, FlumeSlackEnvelopeSchema, FlumeSlackSeenCache, FlumeSlackSocketMode, FlumeSlackSource, extractSlackMeta, obtainSlackUrl };
631
+ export { FlumeSlackSource, flumeExtractSlackMeta };