@interactive-inc/flume 0.1.0 → 0.2.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.
@@ -1,400 +0,0 @@
1
- import { a as FlumeSlackEnvelopeSchema, i as FlumeSlackConnectionResponseSchema, t as FlumeLogger } from "./logger-B9E8zvgj.js";
2
- import { a as FlumeParseError, i as resolveFlumeReconnectConfig, n as isRecord, o as FlumeConnectionError, r as FlumeReconnector, t as safeJsonParse } from "./safe-json-parse-D8t_4Vm_.js";
3
- //#region lib/errors/http-error.ts
4
- var FlumeHttpError = class extends Error {
5
- status;
6
- constructor(props) {
7
- super(props.message);
8
- this.name = "FlumeHttpError";
9
- this.status = props.status;
10
- Object.freeze(this);
11
- }
12
- };
13
- //#endregion
14
- //#region lib/slack/obtain-slack-url.ts
15
- async function obtainSlackUrl(props) {
16
- const log = new FlumeLogger({
17
- source: "slack.url",
18
- handler: props.onLog,
19
- deps: props.deps
20
- });
21
- const url = "https://slack.com/api/apps.connections.open";
22
- log.debug({
23
- action: "http.request",
24
- message: `POST ${url}`
25
- });
26
- const response = await safeFetch(props, url, log);
27
- if (response instanceof FlumeHttpError) return response;
28
- log.debug({
29
- action: "http.response",
30
- message: `POST ${response.status}`,
31
- detail: {
32
- status: response.status,
33
- url
34
- }
35
- });
36
- const raw = await response.json();
37
- log.debug({
38
- action: "http.body",
39
- message: "apps.connections.open response",
40
- detail: {
41
- ok: isOk(raw),
42
- error: errorField(raw)
43
- }
44
- });
45
- const parsed = FlumeSlackConnectionResponseSchema.safeParse(raw);
46
- if (!parsed.success) {
47
- log.warn({
48
- action: "parse.fail",
49
- message: "apps.connections.open: invalid response shape",
50
- detail: { issues: parsed.error.issues.map((i) => ({
51
- path: i.path,
52
- message: i.message
53
- })) }
54
- });
55
- return new FlumeHttpError({
56
- message: "apps.connections.open: invalid response shape",
57
- status: response.status
58
- });
59
- }
60
- if (!parsed.data.ok || !parsed.data.url) {
61
- log.warn({
62
- action: "api.fail",
63
- message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}`
64
- });
65
- return new FlumeHttpError({
66
- message: `apps.connections.open failed: ${parsed.data.error ?? "no url"}`,
67
- status: response.status
68
- });
69
- }
70
- log.info({
71
- action: "url.obtained",
72
- message: "WSS URL obtained"
73
- });
74
- return parsed.data.url;
75
- }
76
- function isOk(raw) {
77
- return raw?.ok;
78
- }
79
- function errorField(raw) {
80
- return raw?.error;
81
- }
82
- async function safeFetch(props, url, log) {
83
- try {
84
- return await props.deps.fetch(url, {
85
- method: "POST",
86
- headers: { Authorization: `Bearer ${props.appToken}` }
87
- });
88
- } catch (error) {
89
- const err = error instanceof Error ? error : new Error(String(error));
90
- log.error({
91
- action: "http.error",
92
- message: `network error: ${err.message}`,
93
- error: err
94
- });
95
- return new FlumeHttpError({
96
- message: err.message,
97
- status: 0
98
- });
99
- }
100
- }
101
- //#endregion
102
- //#region lib/slack/slack-socket-mode.ts
103
- function framePreview(raw) {
104
- return raw.length > 200 ? `${raw.slice(0, 200)}... (${raw.length} bytes)` : raw;
105
- }
106
- var FlumeSlackSocketMode = class {
107
- props;
108
- log;
109
- ws = null;
110
- stopped = false;
111
- pendingResolve = null;
112
- pendingResolved = false;
113
- constructor(props) {
114
- this.props = props;
115
- this.log = new FlumeLogger({
116
- source: "slack.socket-mode",
117
- handler: props.onLog,
118
- deps: props.deps
119
- });
120
- }
121
- async connect() {
122
- this.log.info({
123
- action: "connect.start",
124
- message: "opening WebSocket connection"
125
- });
126
- const url = await obtainSlackUrl({
127
- appToken: this.props.appToken,
128
- onLog: this.props.onLog,
129
- deps: this.props.deps
130
- });
131
- if (url instanceof FlumeHttpError) {
132
- this.log.error({
133
- action: "http.error",
134
- message: url.message,
135
- error: url
136
- });
137
- return url;
138
- }
139
- this.log.info({
140
- action: "url.obtained",
141
- message: "WebSocket URL obtained"
142
- });
143
- return this.openSocket(url);
144
- }
145
- disconnect() {
146
- this.log.info({
147
- action: "disconnect",
148
- message: "stopping socket mode"
149
- });
150
- this.stopped = true;
151
- if (this.ws) {
152
- this.ws.close();
153
- this.ws = null;
154
- }
155
- }
156
- isConnected() {
157
- return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
158
- }
159
- openSocket(url) {
160
- this.pendingResolved = false;
161
- return new Promise((resolve) => {
162
- this.pendingResolve = resolve;
163
- const socket = new this.props.deps.WebSocket(url);
164
- this.ws = socket;
165
- socket.addEventListener("message", (ev) => this.onMessage(String(ev.data), socket));
166
- socket.addEventListener("close", (ev) => this.onClose(ev));
167
- socket.addEventListener("error", () => this.onError());
168
- });
169
- }
170
- completeConnect(error) {
171
- if (this.pendingResolved || !this.pendingResolve) return;
172
- this.pendingResolved = true;
173
- this.pendingResolve(error);
174
- }
175
- onMessage(raw, socket) {
176
- this.log.debug({
177
- action: "ws.recv",
178
- message: framePreview(raw)
179
- });
180
- const json = safeJsonParse(raw);
181
- if (!isRecord(json)) {
182
- this.log.error({
183
- action: "ws.parse-error",
184
- message: "invalid JSON",
185
- error: new FlumeParseError(raw.slice(0, 200))
186
- });
187
- return;
188
- }
189
- if (json.type === "hello") {
190
- this.log.info({
191
- action: "ws.hello",
192
- message: "connection ready"
193
- });
194
- this.props.onConnected();
195
- this.completeConnect(null);
196
- return;
197
- }
198
- if (json.type === "disconnect") {
199
- const reason = typeof json.reason === "string" ? json.reason : "unknown";
200
- this.log.info({
201
- action: "ws.disconnect-requested",
202
- message: `reason=${reason}`,
203
- detail: { reason }
204
- });
205
- socket.close();
206
- return;
207
- }
208
- if (typeof json.envelope_id === "string") {
209
- this.log.debug({
210
- action: "ws.ack",
211
- message: `envelope_id=${json.envelope_id}`
212
- });
213
- const ack = JSON.stringify({ envelope_id: json.envelope_id });
214
- socket.send(ack);
215
- this.log.debug({
216
- action: "ws.send",
217
- message: framePreview(ack)
218
- });
219
- }
220
- const envelope = FlumeSlackEnvelopeSchema.safeParse(json);
221
- if (envelope.success) {
222
- this.log.debug({
223
- action: "envelope.recv",
224
- message: `type=${envelope.data.type} envelope_id=${envelope.data.envelope_id}`,
225
- detail: {
226
- type: envelope.data.type,
227
- envelopeId: envelope.data.envelope_id
228
- }
229
- });
230
- this.props.onMessage(envelope.data);
231
- return;
232
- }
233
- this.log.warn({
234
- action: "envelope.parse-fail",
235
- message: "unrecognised envelope shape, dropping",
236
- detail: {
237
- type: typeof json.type === "string" ? json.type : "unknown",
238
- issues: envelope.error.issues.map((i) => ({
239
- path: i.path,
240
- message: i.message
241
- }))
242
- }
243
- });
244
- }
245
- onClose(ev) {
246
- this.log.info({
247
- action: "ws.close",
248
- message: `code=${ev.code} reason=${ev.reason || "none"}`
249
- });
250
- this.ws = null;
251
- this.props.onDisconnected();
252
- this.completeConnect(new FlumeConnectionError(`WebSocket closed before hello (code=${ev.code})`));
253
- }
254
- onError() {
255
- this.log.error({
256
- action: "ws.error",
257
- message: "WebSocket error event"
258
- });
259
- this.completeConnect(new FlumeConnectionError("WebSocket connection error"));
260
- }
261
- };
262
- //#endregion
263
- //#region lib/slack/slack-source.ts
264
- var FlumeSlackSource = class FlumeSlackSource {
265
- options;
266
- socket = null;
267
- reconnector = null;
268
- handler = null;
269
- currentStatus = "disconnected";
270
- log;
271
- deps;
272
- constructor(options) {
273
- this.options = options;
274
- this.deps = options.deps;
275
- this.log = new FlumeLogger({
276
- source: "slack",
277
- handler: options.onLog,
278
- deps: this.deps
279
- });
280
- const rc = resolveFlumeReconnectConfig(options.reconnect);
281
- if (rc) this.reconnector = new FlumeReconnector({
282
- ...rc,
283
- deps: this.deps
284
- });
285
- }
286
- async start(handler) {
287
- if (this.options.signal?.aborted) return;
288
- this.options.signal?.addEventListener("abort", () => this.stop(), { once: true });
289
- this.handler = handler;
290
- this.log.info({
291
- action: "start",
292
- message: "starting Slack source"
293
- });
294
- await this.connectInternal();
295
- }
296
- async stop() {
297
- this.log.info({
298
- action: "stop",
299
- message: "stopping Slack source"
300
- });
301
- if (this.reconnector && !this.reconnector.aborted) this.log.debug({
302
- action: "reconnect.cancel",
303
- message: "aborting reconnector"
304
- });
305
- this.reconnector?.cancel();
306
- this.socket?.disconnect();
307
- this.socket = null;
308
- this.handler = null;
309
- this.setStatus("disconnected");
310
- }
311
- status() {
312
- return this.currentStatus;
313
- }
314
- async connectInternal() {
315
- this.setStatus("connecting");
316
- this.socket = new FlumeSlackSocketMode({
317
- appToken: this.options.appToken,
318
- onLog: this.options.onLog,
319
- deps: this.deps,
320
- onMessage: (envelope) => this.handleMessage(envelope),
321
- onConnected: () => {
322
- if (this.reconnector && this.reconnector.attempt > 0) this.log.info({
323
- action: "reconnect.reset",
324
- message: `cleared ${this.reconnector.attempt} attempts`
325
- });
326
- this.reconnector?.reset();
327
- this.setStatus("connected");
328
- },
329
- onDisconnected: () => {
330
- if (!this.socket?.stopped) this.scheduleReconnect();
331
- }
332
- });
333
- const error = await this.socket.connect();
334
- if (error instanceof Error) {
335
- this.log.error({
336
- action: "connect.failed",
337
- message: error.message,
338
- error
339
- });
340
- this.scheduleReconnect();
341
- }
342
- }
343
- handleMessage(envelope) {
344
- const event = {
345
- source: "slack",
346
- type: envelope.type,
347
- data: envelope.payload,
348
- meta: FlumeSlackSource.extractMeta(envelope),
349
- receivedAt: this.deps.now()
350
- };
351
- try {
352
- this.handler?.(event);
353
- } catch (err) {
354
- this.log.error({
355
- action: "handler.error",
356
- message: "user handler threw",
357
- error: err instanceof Error ? err : new Error(String(err))
358
- });
359
- }
360
- }
361
- scheduleReconnect() {
362
- if (!this.reconnector || this.reconnector.aborted) {
363
- this.setStatus("disconnected");
364
- return;
365
- }
366
- this.setStatus("reconnecting");
367
- const delay = this.reconnector.schedule(() => this.connectInternal());
368
- if (delay === -1) {
369
- this.log.error({
370
- action: "reconnect.exhausted",
371
- message: `gave up after ${this.reconnector.attempt} attempts`
372
- });
373
- this.setStatus("disconnected");
374
- } else this.log.info({
375
- action: "reconnect.scheduled",
376
- message: `next attempt in ${Math.round(delay)}ms`
377
- });
378
- }
379
- setStatus(next) {
380
- if (this.currentStatus === next) return;
381
- this.log.info({
382
- action: "status",
383
- message: `${this.currentStatus} → ${next}`
384
- });
385
- this.currentStatus = next;
386
- this.options.onStatus?.(next);
387
- }
388
- static extractMeta(envelope) {
389
- const meta = { event_type: envelope.type };
390
- const eventPayload = isRecord(envelope.payload.event) ? envelope.payload.event : null;
391
- if (!eventPayload) return meta;
392
- if (typeof eventPayload.channel === "string") meta.channel_id = eventPayload.channel;
393
- if (typeof eventPayload.user === "string") meta.user_id = eventPayload.user;
394
- if (typeof eventPayload.thread_ts === "string") meta.thread_ts = eventPayload.thread_ts;
395
- if (typeof eventPayload.type === "string") meta.slack_event_type = eventPayload.type;
396
- return meta;
397
- }
398
- };
399
- //#endregion
400
- export { FlumeHttpError as i, FlumeSlackSocketMode as n, obtainSlackUrl as r, FlumeSlackSource as t };