@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/README.md +60 -39
- package/dist/connection-error-HUO3PC3G.js +16 -0
- package/dist/discord.d.ts +10 -85
- package/dist/discord.js +474 -175
- package/dist/github.d.ts +9 -48
- package/dist/github.js +391 -128
- package/dist/{http-error-BtXonO-W.js → http-error-CPSKoSie.js} +1 -1
- package/dist/index.d.ts +76 -45
- package/dist/index.js +225 -40
- package/dist/safe-invoke-callback-EpWXwfwp.js +170 -0
- package/dist/safe-read-text-DgrJ4Uhl.js +20 -0
- package/dist/safe-stringify-BWS-uXZP.js +172 -0
- package/dist/serial-queue-B9LoBc64.js +162 -0
- package/dist/slack.d.ts +10 -62
- package/dist/slack.js +377 -131
- package/dist/{types-tnOPBc1p.d.ts → types-D-tO-Mh2.d.ts} +36 -10
- package/package.json +21 -18
- package/dist/connection-error-BOk97djj.d.ts +0 -6
- package/dist/http-error-K-Ym4lfK.d.ts +0 -11
- package/dist/logger-CpGB9WO_.js +0 -49
- package/dist/parse-error-BAiCLRmk.d.ts +0 -6
- package/dist/safe-fetch-30ZzOKHL.js +0 -20
- package/dist/safe-json-parse-WCg_x1JS.js +0 -15
- package/dist/schedule-reconnect-DSxZJG3h.js +0 -93
- package/dist/serial-queue-ExmlnpzQ.js +0 -16
package/dist/slack.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { n as createFlumeDefaultDeps, t as
|
|
2
|
-
import {
|
|
3
|
-
import { t as FlumeHttpError } from "./http-error-
|
|
4
|
-
import { t as
|
|
5
|
-
import { n as
|
|
6
|
-
import { t as
|
|
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
|
|
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
|
|
23
|
-
* source
|
|
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
|
|
28
|
+
seen = /* @__PURE__ */ new Map();
|
|
28
29
|
constructor(props) {
|
|
29
30
|
this.props = props;
|
|
30
31
|
}
|
|
31
32
|
has(envelopeId) {
|
|
32
|
-
|
|
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.
|
|
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
|
|
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 ${
|
|
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
|
|
98
|
-
|
|
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(
|
|
131
|
+
const parsed = FlumeSlackConnectionResponseSchema.safeParse(json);
|
|
108
132
|
if (!parsed.success) {
|
|
109
133
|
log.warn({
|
|
110
|
-
action: "
|
|
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.
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
188
|
-
|
|
189
|
-
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
213
|
-
this.
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
221
|
-
message: "
|
|
222
|
-
error: new FlumeParseError(
|
|
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: "
|
|
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
|
|
368
|
+
action: "ws.disconnect.requested",
|
|
239
369
|
message: `reason=${reason}`,
|
|
240
370
|
detail: { reason }
|
|
241
371
|
});
|
|
242
|
-
|
|
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 =
|
|
251
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
481
|
+
internalController = null;
|
|
309
482
|
log;
|
|
310
483
|
deps;
|
|
311
484
|
queue = new FlumeSerialQueue();
|
|
312
|
-
seen
|
|
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?.
|
|
329
|
-
this.
|
|
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
|
-
|
|
351
|
-
this.
|
|
566
|
+
this.internalController = null;
|
|
567
|
+
this.statusEmitter.set("disconnected");
|
|
352
568
|
}
|
|
353
569
|
status() {
|
|
354
|
-
return this.
|
|
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.
|
|
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.
|
|
598
|
+
this.statusEmitter.set("connected");
|
|
370
599
|
},
|
|
371
600
|
onDisconnected: () => {
|
|
372
|
-
if (
|
|
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
|
|
612
|
+
message: safeErrorMessage({ error }),
|
|
380
613
|
error
|
|
381
614
|
});
|
|
382
|
-
if (!this.reconnector || this.reconnector.aborted) {
|
|
383
|
-
this.
|
|
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
|
|
404
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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.
|
|
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 {
|
|
689
|
+
export { FlumeSlackSource, flumeExtractSlackMeta };
|