@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/README.md +67 -53
- package/dist/connection-error-HUO3PC3G.js +16 -0
- package/dist/discord.d.ts +10 -85
- package/dist/discord.js +473 -183
- package/dist/github.d.ts +9 -48
- package/dist/github.js +391 -135
- package/dist/{http-error-BtXonO-W.js → http-error-CPSKoSie.js} +1 -1
- package/dist/index.d.ts +76 -34
- package/dist/index.js +225 -57
- 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 +376 -139
- package/dist/{types-Bm9uKUQz.d.ts → types-D-tO-Mh2.d.ts} +32 -21
- 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/reconnector-BDoJ1xNX.js +0 -67
- package/dist/safe-fetch-30ZzOKHL.js +0 -20
- package/dist/safe-json-parse-BWlzGOLl.js +0 -41
- 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
|
|
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
|
|
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
|
|
295
430
|
});
|
|
296
|
-
this.completeConnect(
|
|
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
|
-
|
|
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,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?.
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
this.
|
|
566
|
+
this.internalController = null;
|
|
567
|
+
this.statusEmitter.set("disconnected");
|
|
360
568
|
}
|
|
361
569
|
status() {
|
|
362
|
-
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;
|
|
363
584
|
}
|
|
364
585
|
async connectInternal() {
|
|
365
|
-
this.
|
|
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.
|
|
598
|
+
this.statusEmitter.set("connected");
|
|
378
599
|
},
|
|
379
600
|
onDisconnected: () => {
|
|
380
|
-
if (
|
|
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
|
|
612
|
+
message: safeErrorMessage({ error }),
|
|
388
613
|
error
|
|
389
614
|
});
|
|
390
|
-
if (!this.reconnector || this.reconnector.aborted) {
|
|
391
|
-
this.
|
|
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
|
|
413
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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.
|
|
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 {
|
|
689
|
+
export { FlumeSlackSource, flumeExtractSlackMeta };
|