@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/dist/slack.js CHANGED
@@ -1,12 +1,12 @@
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";
1
+ import { a as FlumeParseError, c as safeNormalizeError, i as FlumeStartError, l as safeErrorMessage, n as FlumeLogger, o as createFlumeDefaultDeps, r as safeNow, s as attempt, t as safeInvokeCallback } from "./safe-invoke-callback-EpWXwfwp.js";
2
+ import { t as FlumeConnectionError } from "./connection-error-HUO3PC3G.js";
3
+ import { t as FlumeHttpError } from "./http-error-CPSKoSie.js";
4
+ import { a as FlumeReconnector, i as resolveFlumeReconnectConfig, n as isRecord, r as scheduleFlumeReconnect, t as safeStringify } from "./safe-stringify-BWS-uXZP.js";
5
+ import { i as safeJsonParse, n as FlumeStatusEmitter, r as FlumeSignalRegistry, t as FlumeSerialQueue } from "./serial-queue-B9LoBc64.js";
6
+ import { t as safeReadText } from "./safe-read-text-DgrJ4Uhl.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
216
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
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,34 +408,95 @@ 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
440
+ });
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
295
456
  });
296
- this.completeConnect(new FlumeConnectionError("WebSocket connection error"));
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;
474
+ const SEEN_CACHE_TTL_MS = 300 * 1e3;
302
475
  var FlumeSlackSource = class {
303
476
  options;
304
477
  name = "slack";
305
478
  socket = null;
306
479
  reconnector = null;
307
480
  handler = null;
308
- currentStatus = "disconnected";
481
+ internalController = null;
309
482
  log;
310
483
  deps;
311
484
  queue = new FlumeSerialQueue();
312
- seen = new FlumeSlackSeenCache({ maxSize: SEEN_CACHE_MAX });
485
+ seen;
486
+ signals;
487
+ statusEmitter;
488
+ onSignalAbort = () => {
489
+ safeInvokeCallback({
490
+ fn: () => this.stop(),
491
+ onError: (error) => {
492
+ this.log.error({
493
+ action: "signal.abort.stop.failed",
494
+ message: safeErrorMessage({ error }),
495
+ error
496
+ });
497
+ }
498
+ });
499
+ };
313
500
  constructor(options) {
314
501
  this.options = options;
315
502
  this.deps = options.deps ?? createFlumeDefaultDeps();
@@ -318,25 +505,52 @@ var FlumeSlackSource = class {
318
505
  handler: options.onLog,
319
506
  deps: this.deps
320
507
  });
508
+ this.signals = new FlumeSignalRegistry({
509
+ log: this.log,
510
+ onAbort: this.onSignalAbort
511
+ });
512
+ this.statusEmitter = new FlumeStatusEmitter({
513
+ log: this.log,
514
+ onStatus: options.onStatus
515
+ });
516
+ this.seen = new FlumeSlackSeenCache({
517
+ maxSize: SEEN_CACHE_MAX,
518
+ ttlMs: SEEN_CACHE_TTL_MS,
519
+ deps: this.deps
520
+ });
321
521
  const rc = resolveFlumeReconnectConfig(options.reconnect);
322
522
  if (rc) this.reconnector = new FlumeReconnector({
323
523
  ...rc,
524
+ log: this.log,
324
525
  deps: this.deps
325
526
  });
326
527
  }
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 });
528
+ async start(handler, options) {
529
+ if (this.signals.isAnyAborted(this.options.signal) || this.signals.isAnyAborted(options?.signal)) return new FlumeStartError("Slack source: signal already aborted");
530
+ if (!this.hasWebSocket()) return new FlumeStartError("Slack source: deps.WebSocket is null (no WebSocket runtime available)");
531
+ this.signals.register(this.options.signal);
532
+ this.signals.register(options?.signal);
330
533
  this.handler = handler;
534
+ const controllerResult = attempt(() => new AbortController());
535
+ if (controllerResult instanceof Error) {
536
+ const error = safeNormalizeError({ value: controllerResult });
537
+ this.log.error({
538
+ action: "slack.abort-controller.new.error",
539
+ message: safeErrorMessage({ error }),
540
+ error
541
+ });
542
+ this.internalController = null;
543
+ } else this.internalController = controllerResult;
331
544
  this.log.info({
332
- action: "start",
545
+ action: "source.start",
333
546
  message: "starting Slack source"
334
547
  });
335
- return this.connectInternal();
548
+ return await this.connectInternal();
336
549
  }
337
550
  async stop() {
551
+ this.signals.unregisterAll();
338
552
  this.log.info({
339
- action: "stop",
553
+ action: "source.stop",
340
554
  message: "stopping Slack source"
341
555
  });
342
556
  if (this.reconnector && !this.reconnector.aborted) this.log.debug({
@@ -344,17 +558,32 @@ var FlumeSlackSource = class {
344
558
  message: "aborting reconnector"
345
559
  });
346
560
  this.reconnector?.cancel();
561
+ this.internalController?.abort();
347
562
  this.socket?.disconnect();
563
+ await this.queue.drain();
348
564
  this.socket = null;
349
565
  this.handler = null;
350
- await this.queue.drain();
351
- this.setStatus("disconnected");
566
+ this.internalController = null;
567
+ this.statusEmitter.set("disconnected");
352
568
  }
353
569
  status() {
354
- return this.currentStatus;
570
+ return this.statusEmitter.value;
571
+ }
572
+ hasWebSocket() {
573
+ const result = attempt(() => Boolean(this.deps.WebSocket));
574
+ if (result instanceof Error) {
575
+ const error = safeNormalizeError({ value: result });
576
+ this.log.error({
577
+ action: "deps.web-socket.read.error",
578
+ message: safeErrorMessage({ error }),
579
+ error
580
+ });
581
+ return false;
582
+ }
583
+ return result;
355
584
  }
356
585
  async connectInternal() {
357
- this.setStatus("connecting");
586
+ this.statusEmitter.set("connecting");
358
587
  this.socket = new FlumeSlackSocketMode({
359
588
  appToken: this.options.appToken,
360
589
  onLog: this.options.onLog,
@@ -366,25 +595,30 @@ var FlumeSlackSource = class {
366
595
  message: `cleared ${this.reconnector.attempt} attempts`
367
596
  });
368
597
  this.reconnector?.reset();
369
- this.setStatus("connected");
598
+ this.statusEmitter.set("connected");
370
599
  },
371
600
  onDisconnected: () => {
372
- if (!this.socket?.stopped) this.scheduleReconnect();
601
+ if (this.socket?.isStopped) {
602
+ this.statusEmitter.set("disconnected");
603
+ return;
604
+ }
605
+ this.scheduleReconnect();
373
606
  }
374
607
  });
375
- const error = await this.socket.connect();
608
+ const error = await this.socket.connect({ signal: this.internalController?.signal });
376
609
  if (error instanceof Error) {
377
610
  this.log.error({
378
611
  action: "connect.failed",
379
- message: error.message,
612
+ message: safeErrorMessage({ error }),
380
613
  error
381
614
  });
382
- if (!this.reconnector || this.reconnector.aborted) {
383
- this.setStatus("disconnected");
615
+ if (this.socket.isStopped || !this.reconnector || this.reconnector.aborted) {
616
+ this.statusEmitter.set("disconnected");
384
617
  return error;
385
618
  }
386
619
  this.scheduleReconnect();
387
620
  }
621
+ return null;
388
622
  }
389
623
  handleMessage(envelope) {
390
624
  if (this.seen.has(envelope.envelope_id)) {
@@ -400,44 +634,56 @@ var FlumeSlackSource = class {
400
634
  }
401
635
  this.seen.add(envelope.envelope_id);
402
636
  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
- };
637
+ const handler = this.handler;
638
+ if (!handler) return;
410
639
  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
- }
640
+ const event = {
641
+ source: "slack",
642
+ type: envelope.type,
643
+ data: envelope.payload,
644
+ meta: this.safeExtractMeta(envelope),
645
+ receivedAt: safeNow({ deps: this.deps })
646
+ };
647
+ const r = await attempt(() => Promise.resolve(handler(event)));
648
+ if (r instanceof Error) this.log.error({
649
+ action: "handler.error",
650
+ message: safeErrorMessage({ error: r }),
651
+ error: r
652
+ });
420
653
  });
421
654
  }
655
+ safeExtractMeta(envelope) {
656
+ const result = attempt(() => flumeExtractSlackMeta(envelope));
657
+ if (result instanceof Error) {
658
+ const error = safeNormalizeError({ value: result });
659
+ this.log.warn({
660
+ action: "meta.extract.error",
661
+ message: safeErrorMessage({ error }),
662
+ error,
663
+ detail: { envelopeType: envelope.type }
664
+ });
665
+ return { event_type: envelope.type };
666
+ }
667
+ return result;
668
+ }
422
669
  scheduleReconnect() {
423
670
  scheduleFlumeReconnect({
424
671
  reconnector: this.reconnector,
425
672
  log: this.log,
426
- setStatus: (status) => this.setStatus(status),
673
+ setStatus: (status) => this.statusEmitter.set(status),
427
674
  retry: () => {
428
- this.connectInternal();
675
+ this.connectInternal().catch((err) => {
676
+ const error = safeNormalizeError({ value: err });
677
+ this.log.error({
678
+ action: "reconnect.unhandled",
679
+ message: safeErrorMessage({ error }),
680
+ error
681
+ });
682
+ this.statusEmitter.set("disconnected");
683
+ });
429
684
  }
430
685
  });
431
686
  }
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
687
  };
442
688
  //#endregion
443
- export { FlumeSlackConnectionResponseSchema, FlumeSlackEnvelopeSchema, FlumeSlackSeenCache, FlumeSlackSocketMode, FlumeSlackSource, extractSlackMeta, obtainSlackUrl };
689
+ export { FlumeSlackSource, flumeExtractSlackMeta };