@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/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 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
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
216
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
295
430
  });
296
- this.completeConnect(new FlumeConnectionError("WebSocket connection error"));
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
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;
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,33 +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 {
329
- ok: false,
330
- error: /* @__PURE__ */ new Error("Slack source: signal already aborted")
331
- };
332
- 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);
333
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;
334
544
  this.log.info({
335
- action: "start",
545
+ action: "source.start",
336
546
  message: "starting Slack source"
337
547
  });
338
- const result = await this.connectInternal();
339
- if (result instanceof Error) return {
340
- ok: false,
341
- error: result
342
- };
343
- return { ok: true };
548
+ return await this.connectInternal();
344
549
  }
345
550
  async stop() {
551
+ this.signals.unregisterAll();
346
552
  this.log.info({
347
- action: "stop",
553
+ action: "source.stop",
348
554
  message: "stopping Slack source"
349
555
  });
350
556
  if (this.reconnector && !this.reconnector.aborted) this.log.debug({
@@ -352,17 +558,32 @@ var FlumeSlackSource = class {
352
558
  message: "aborting reconnector"
353
559
  });
354
560
  this.reconnector?.cancel();
561
+ this.internalController?.abort();
355
562
  this.socket?.disconnect();
563
+ await this.queue.drain();
356
564
  this.socket = null;
357
565
  this.handler = null;
358
- await this.queue.drain();
359
- this.setStatus("disconnected");
566
+ this.internalController = null;
567
+ this.statusEmitter.set("disconnected");
360
568
  }
361
569
  status() {
362
- 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;
363
584
  }
364
585
  async connectInternal() {
365
- this.setStatus("connecting");
586
+ this.statusEmitter.set("connecting");
366
587
  this.socket = new FlumeSlackSocketMode({
367
588
  appToken: this.options.appToken,
368
589
  onLog: this.options.onLog,
@@ -374,21 +595,25 @@ var FlumeSlackSource = class {
374
595
  message: `cleared ${this.reconnector.attempt} attempts`
375
596
  });
376
597
  this.reconnector?.reset();
377
- this.setStatus("connected");
598
+ this.statusEmitter.set("connected");
378
599
  },
379
600
  onDisconnected: () => {
380
- if (!this.socket?.stopped) this.scheduleReconnect();
601
+ if (this.socket?.isStopped) {
602
+ this.statusEmitter.set("disconnected");
603
+ return;
604
+ }
605
+ this.scheduleReconnect();
381
606
  }
382
607
  });
383
- const error = await this.socket.connect();
608
+ const error = await this.socket.connect({ signal: this.internalController?.signal });
384
609
  if (error instanceof Error) {
385
610
  this.log.error({
386
611
  action: "connect.failed",
387
- message: error.message,
612
+ message: safeErrorMessage({ error }),
388
613
  error
389
614
  });
390
- if (!this.reconnector || this.reconnector.aborted) {
391
- this.setStatus("disconnected");
615
+ if (this.socket.isStopped || !this.reconnector || this.reconnector.aborted) {
616
+ this.statusEmitter.set("disconnected");
392
617
  return error;
393
618
  }
394
619
  this.scheduleReconnect();
@@ -409,44 +634,56 @@ var FlumeSlackSource = class {
409
634
  }
410
635
  this.seen.add(envelope.envelope_id);
411
636
  this.seen.trim();
412
- const event = {
413
- source: "slack",
414
- type: envelope.type,
415
- data: envelope.payload,
416
- meta: extractSlackMeta(envelope),
417
- receivedAt: this.deps.now()
418
- };
637
+ const handler = this.handler;
638
+ if (!handler) return;
419
639
  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
- }
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
+ });
429
653
  });
430
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
+ }
431
669
  scheduleReconnect() {
432
670
  scheduleFlumeReconnect({
433
671
  reconnector: this.reconnector,
434
672
  log: this.log,
435
- setStatus: (status) => this.setStatus(status),
673
+ setStatus: (status) => this.statusEmitter.set(status),
436
674
  retry: () => {
437
- 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
+ });
438
684
  }
439
685
  });
440
686
  }
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
687
  };
451
688
  //#endregion
452
- export { FlumeSlackConnectionResponseSchema, FlumeSlackEnvelopeSchema, FlumeSlackSeenCache, FlumeSlackSocketMode, FlumeSlackSource, extractSlackMeta, obtainSlackUrl };
689
+ export { FlumeSlackSource, flumeExtractSlackMeta };