@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/github.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { n as createFlumeDefaultDeps, t as
|
|
2
|
-
import { t as
|
|
3
|
-
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 FlumeHttpError } from "./http-error-CPSKoSie.js";
|
|
3
|
+
import { i as safeJsonParse, n as FlumeStatusEmitter, r as FlumeSignalRegistry, t as FlumeSerialQueue } from "./serial-queue-B9LoBc64.js";
|
|
4
|
+
import { t as safeReadText } from "./safe-read-text-DgrJ4Uhl.js";
|
|
4
5
|
import { z } from "zod/v4";
|
|
5
6
|
//#region lib/github/extract-github-meta.ts
|
|
6
|
-
function
|
|
7
|
+
function flumeExtractGitHubMeta(notification) {
|
|
7
8
|
return {
|
|
8
9
|
event_type: "notification",
|
|
9
10
|
reason: notification.reason,
|
|
@@ -51,15 +52,28 @@ var FlumeGitHubSeenCache = class {
|
|
|
51
52
|
};
|
|
52
53
|
//#endregion
|
|
53
54
|
//#region lib/github/github-poller.ts
|
|
55
|
+
const NOTIFICATIONS_URL = "https://api.github.com/notifications";
|
|
56
|
+
const CONSECUTIVE_ERRORS_TO_DISCONNECT = 3;
|
|
57
|
+
const SEEN_CACHE_MAX = 5e3;
|
|
58
|
+
/**
|
|
59
|
+
* GitHub /notifications を条件付きポーリングする (ETag / Last-Modified)。
|
|
60
|
+
* 304 / X-Poll-Interval / レート制限を尊重し、stop() / abort() で in-flight 通信を打ち切る
|
|
61
|
+
*/
|
|
54
62
|
var FlumeGitHubPoller = class {
|
|
55
63
|
props;
|
|
56
64
|
log;
|
|
57
|
-
cache = new FlumeGitHubSeenCache({ maxSize:
|
|
65
|
+
cache = new FlumeGitHubSeenCache({ maxSize: SEEN_CACHE_MAX });
|
|
58
66
|
timer = null;
|
|
67
|
+
rateLimitTimer = null;
|
|
59
68
|
since = null;
|
|
69
|
+
etag = null;
|
|
70
|
+
lastModified = null;
|
|
60
71
|
bootstrapped = false;
|
|
61
|
-
|
|
72
|
+
isStoppedFlag = false;
|
|
73
|
+
inFlight = false;
|
|
62
74
|
consecutiveErrors = 0;
|
|
75
|
+
effectiveIntervalSec;
|
|
76
|
+
controller = null;
|
|
63
77
|
constructor(props) {
|
|
64
78
|
this.props = props;
|
|
65
79
|
this.log = new FlumeLogger({
|
|
@@ -67,45 +81,125 @@ var FlumeGitHubPoller = class {
|
|
|
67
81
|
handler: props.onLog,
|
|
68
82
|
deps: props.deps
|
|
69
83
|
});
|
|
84
|
+
this.effectiveIntervalSec = props.interval;
|
|
85
|
+
}
|
|
86
|
+
get isStopped() {
|
|
87
|
+
return this.isStoppedFlag;
|
|
70
88
|
}
|
|
71
89
|
async start() {
|
|
72
|
-
this.
|
|
90
|
+
this.isStoppedFlag = false;
|
|
91
|
+
const controllerResult = attempt(() => new AbortController());
|
|
92
|
+
if (controllerResult instanceof Error) {
|
|
93
|
+
this.log.error({
|
|
94
|
+
action: "github.abort-controller.new.error",
|
|
95
|
+
message: safeErrorMessage({ error: controllerResult }),
|
|
96
|
+
error: controllerResult
|
|
97
|
+
});
|
|
98
|
+
this.controller = null;
|
|
99
|
+
} else this.controller = controllerResult;
|
|
73
100
|
this.log.info({
|
|
74
|
-
action: "start",
|
|
75
|
-
message: `polling every ${this.
|
|
101
|
+
action: "poller.start",
|
|
102
|
+
message: `polling every ${this.effectiveIntervalSec}s`
|
|
76
103
|
});
|
|
77
|
-
await this.poll();
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
message: "unexpected error in poll loop",
|
|
83
|
-
error: err instanceof Error ? err : new Error(String(err))
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
}, this.props.interval * 1e3);
|
|
104
|
+
const error = await this.poll();
|
|
105
|
+
if (error) return error;
|
|
106
|
+
if (this.isStoppedFlag) return null;
|
|
107
|
+
this.scheduleInterval();
|
|
108
|
+
return null;
|
|
87
109
|
}
|
|
88
110
|
stop() {
|
|
89
111
|
this.log.info({
|
|
90
|
-
action: "stop",
|
|
112
|
+
action: "poller.stop",
|
|
91
113
|
message: "stopping poller"
|
|
92
114
|
});
|
|
93
|
-
this.
|
|
94
|
-
|
|
95
|
-
|
|
115
|
+
this.isStoppedFlag = true;
|
|
116
|
+
this.controller?.abort();
|
|
117
|
+
this.controller = null;
|
|
118
|
+
this.clearTimer();
|
|
119
|
+
this.clearRateLimitTimer();
|
|
120
|
+
}
|
|
121
|
+
scheduleInterval() {
|
|
122
|
+
this.clearTimer();
|
|
123
|
+
const intervalResult = attempt(() => this.props.deps.setInterval(() => {
|
|
124
|
+
this.poll().catch((err) => {
|
|
125
|
+
const error = safeNormalizeError({ value: err });
|
|
126
|
+
this.log.error({
|
|
127
|
+
action: "poll.unhandled",
|
|
128
|
+
message: safeErrorMessage({ error }),
|
|
129
|
+
error
|
|
130
|
+
});
|
|
131
|
+
}).catch(() => {});
|
|
132
|
+
}, this.effectiveIntervalSec * 1e3));
|
|
133
|
+
if (intervalResult instanceof Error) {
|
|
134
|
+
this.log.error({
|
|
135
|
+
action: "poller.interval.schedule.error",
|
|
136
|
+
message: safeErrorMessage({ error: intervalResult }),
|
|
137
|
+
error: intervalResult
|
|
138
|
+
});
|
|
96
139
|
this.timer = null;
|
|
140
|
+
if (!this.isStoppedFlag) this.props.onDisconnected("interval scheduling rejected by runtime");
|
|
141
|
+
return;
|
|
97
142
|
}
|
|
143
|
+
this.timer = intervalResult;
|
|
98
144
|
}
|
|
99
145
|
async poll() {
|
|
100
|
-
|
|
146
|
+
if (this.inFlight || this.isStoppedFlag) return null;
|
|
147
|
+
this.inFlight = true;
|
|
148
|
+
try {
|
|
149
|
+
return await this.pollOnce();
|
|
150
|
+
} catch (err) {
|
|
151
|
+
const cause = safeNormalizeError({ value: err });
|
|
152
|
+
const error = new FlumeHttpError({
|
|
153
|
+
message: `poll loop threw: ${safeErrorMessage({ error: cause })}`,
|
|
154
|
+
status: 0,
|
|
155
|
+
cause
|
|
156
|
+
});
|
|
157
|
+
this.log.error({
|
|
158
|
+
action: "poll.unhandled",
|
|
159
|
+
message: safeErrorMessage({ error }),
|
|
160
|
+
error
|
|
161
|
+
});
|
|
162
|
+
if (!this.bootstrapped) return error;
|
|
163
|
+
return null;
|
|
164
|
+
} finally {
|
|
165
|
+
this.inFlight = false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async pollOnce() {
|
|
169
|
+
const params = new URLSearchParams({
|
|
170
|
+
all: "false",
|
|
171
|
+
per_page: "50"
|
|
172
|
+
});
|
|
101
173
|
if (this.since) params.set("since", this.since);
|
|
102
|
-
const url =
|
|
174
|
+
const url = `${NOTIFICATIONS_URL}?${params}`;
|
|
103
175
|
this.log.debug({
|
|
104
176
|
action: "http.request",
|
|
105
177
|
message: `GET ${url}`
|
|
106
178
|
});
|
|
107
|
-
const
|
|
108
|
-
|
|
179
|
+
const headers = {
|
|
180
|
+
Authorization: `Bearer ${this.props.token}`,
|
|
181
|
+
Accept: "application/vnd.github+json",
|
|
182
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
183
|
+
};
|
|
184
|
+
if (this.etag) headers["If-None-Match"] = this.etag;
|
|
185
|
+
if (this.lastModified) headers["If-Modified-Since"] = this.lastModified;
|
|
186
|
+
const response = await attempt(() => this.props.deps.fetch(url, {
|
|
187
|
+
headers,
|
|
188
|
+
signal: this.controller?.signal
|
|
189
|
+
}));
|
|
190
|
+
if (this.isStoppedFlag) return null;
|
|
191
|
+
if (response instanceof Error) {
|
|
192
|
+
this.log.error({
|
|
193
|
+
action: "http.error",
|
|
194
|
+
message: safeErrorMessage({ error: response }),
|
|
195
|
+
error: response
|
|
196
|
+
});
|
|
197
|
+
return this.recordFailure({
|
|
198
|
+
kind: "network",
|
|
199
|
+
message: response.message,
|
|
200
|
+
cause: response
|
|
201
|
+
});
|
|
202
|
+
}
|
|
109
203
|
this.log.debug({
|
|
110
204
|
action: "http.response",
|
|
111
205
|
message: `GET ${response.status}`,
|
|
@@ -114,45 +208,175 @@ var FlumeGitHubPoller = class {
|
|
|
114
208
|
url
|
|
115
209
|
}
|
|
116
210
|
});
|
|
211
|
+
this.maybeWidenInterval(response.headers.get("X-Poll-Interval"));
|
|
212
|
+
if (response.status === 304) {
|
|
213
|
+
this.consecutiveErrors = 0;
|
|
214
|
+
this.log.debug({
|
|
215
|
+
action: "poll.not-modified",
|
|
216
|
+
message: "304 Not Modified"
|
|
217
|
+
});
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
if (this.isRateLimited(response)) {
|
|
221
|
+
this.handleRateLimit(response);
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
117
224
|
if (!response.ok) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
225
|
+
const error = new FlumeHttpError({
|
|
226
|
+
message: `HTTP ${response.status}`,
|
|
227
|
+
status: response.status
|
|
228
|
+
});
|
|
229
|
+
return this.recordFailure({
|
|
230
|
+
kind: "http",
|
|
231
|
+
message: safeErrorMessage({ error }),
|
|
232
|
+
error
|
|
122
233
|
});
|
|
123
|
-
if (this.consecutiveErrors >= 3) this.props.onDisconnected(`HTTP ${response.status}`);
|
|
124
|
-
return;
|
|
125
234
|
}
|
|
126
235
|
this.consecutiveErrors = 0;
|
|
127
|
-
|
|
128
|
-
|
|
236
|
+
this.etag = response.headers.get("ETag");
|
|
237
|
+
this.lastModified = response.headers.get("Last-Modified");
|
|
238
|
+
const text = await safeReadText({
|
|
239
|
+
response,
|
|
240
|
+
context: "notifications"
|
|
241
|
+
});
|
|
242
|
+
if (this.isStoppedFlag) return null;
|
|
243
|
+
if (text instanceof FlumeHttpError) {
|
|
244
|
+
this.log.warn({
|
|
245
|
+
action: "http.body.read",
|
|
246
|
+
message: safeErrorMessage({ error: text }),
|
|
247
|
+
error: text
|
|
248
|
+
});
|
|
249
|
+
return this.recordFailure({
|
|
250
|
+
kind: "http",
|
|
251
|
+
message: text.message,
|
|
252
|
+
error: text
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const json = safeJsonParse(text);
|
|
256
|
+
if (json instanceof FlumeParseError) {
|
|
129
257
|
this.log.warn({
|
|
130
|
-
action: "http.body",
|
|
258
|
+
action: "http.body.parse",
|
|
259
|
+
message: json.message,
|
|
260
|
+
error: json
|
|
261
|
+
});
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
if (!Array.isArray(json)) {
|
|
265
|
+
this.log.warn({
|
|
266
|
+
action: "http.body.shape",
|
|
131
267
|
message: "response body is not an array, dropping",
|
|
132
|
-
detail: { bodyType: typeof
|
|
268
|
+
detail: { bodyType: typeof json }
|
|
133
269
|
});
|
|
134
|
-
return;
|
|
270
|
+
return null;
|
|
135
271
|
}
|
|
136
|
-
this.processNotifications(
|
|
272
|
+
this.processNotifications(json);
|
|
273
|
+
return null;
|
|
137
274
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
275
|
+
recordFailure(input) {
|
|
276
|
+
this.consecutiveErrors++;
|
|
277
|
+
const error = input.error ?? new FlumeHttpError({
|
|
278
|
+
message: input.message,
|
|
279
|
+
status: 0,
|
|
280
|
+
cause: input.cause
|
|
281
|
+
});
|
|
282
|
+
this.log.error({
|
|
283
|
+
action: "http.error",
|
|
284
|
+
message: input.message,
|
|
285
|
+
error,
|
|
286
|
+
detail: { consecutiveErrors: this.consecutiveErrors }
|
|
287
|
+
});
|
|
288
|
+
if (this.consecutiveErrors >= CONSECUTIVE_ERRORS_TO_DISCONNECT && !this.isStoppedFlag) this.props.onDisconnected(input.kind === "network" ? "network error" : input.message);
|
|
289
|
+
if (!this.bootstrapped) return error;
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
isRateLimited(response) {
|
|
293
|
+
if (response.status === 429) return true;
|
|
294
|
+
if (response.status === 403 && response.headers.get("X-RateLimit-Remaining") === "0") return true;
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
handleRateLimit(response) {
|
|
298
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
299
|
+
const reset = response.headers.get("X-RateLimit-Reset");
|
|
300
|
+
const nowSec = Math.floor(safeNow({ deps: this.props.deps }) / 1e3);
|
|
301
|
+
const retryAfterSec = retryAfter !== null ? Number.parseInt(retryAfter, 10) : NaN;
|
|
302
|
+
const resetSec = reset !== null ? Number.parseInt(reset, 10) - nowSec : NaN;
|
|
303
|
+
const delaySec = Number.isFinite(retryAfterSec) && retryAfterSec > 0 ? retryAfterSec : Number.isFinite(resetSec) && resetSec > 0 ? resetSec : 60;
|
|
304
|
+
this.log.warn({
|
|
305
|
+
action: "rate.limit",
|
|
306
|
+
message: `rate limited, pausing for ${delaySec}s`,
|
|
307
|
+
detail: {
|
|
308
|
+
status: response.status,
|
|
309
|
+
delaySec
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
this.clearTimer();
|
|
313
|
+
this.clearRateLimitTimer();
|
|
314
|
+
const timerResult = attempt(() => this.props.deps.setTimeout(() => {
|
|
315
|
+
this.rateLimitTimer = null;
|
|
316
|
+
if (this.isStoppedFlag) return;
|
|
317
|
+
this.scheduleInterval();
|
|
318
|
+
}, delaySec * 1e3));
|
|
319
|
+
if (timerResult instanceof Error) {
|
|
320
|
+
this.log.error({
|
|
321
|
+
action: "poller.rate-limit.schedule.error",
|
|
322
|
+
message: safeErrorMessage({ error: timerResult }),
|
|
323
|
+
error: timerResult
|
|
324
|
+
});
|
|
325
|
+
this.rateLimitTimer = null;
|
|
326
|
+
} else this.rateLimitTimer = timerResult;
|
|
327
|
+
}
|
|
328
|
+
clearTimer() {
|
|
329
|
+
if (this.timer === null) return;
|
|
330
|
+
const handle = this.timer;
|
|
331
|
+
const result = attempt(() => this.props.deps.clearInterval(handle));
|
|
332
|
+
if (result instanceof Error) this.log.error({
|
|
333
|
+
action: "poller.timer.clear.error",
|
|
334
|
+
message: safeErrorMessage({ error: result }),
|
|
335
|
+
error: result
|
|
336
|
+
});
|
|
337
|
+
this.timer = null;
|
|
338
|
+
}
|
|
339
|
+
clearRateLimitTimer() {
|
|
340
|
+
if (this.rateLimitTimer === null) return;
|
|
341
|
+
const handle = this.rateLimitTimer;
|
|
342
|
+
const result = attempt(() => this.props.deps.clearTimeout(handle));
|
|
343
|
+
if (result instanceof Error) this.log.error({
|
|
344
|
+
action: "poller.rate-limit.clear.error",
|
|
345
|
+
message: safeErrorMessage({ error: result }),
|
|
346
|
+
error: result
|
|
347
|
+
});
|
|
348
|
+
this.rateLimitTimer = null;
|
|
349
|
+
}
|
|
350
|
+
maybeWidenInterval(headerValue) {
|
|
351
|
+
if (headerValue === null) return;
|
|
352
|
+
const required = Number.parseInt(headerValue, 10);
|
|
353
|
+
if (!Number.isFinite(required) || required <= this.effectiveIntervalSec) return;
|
|
354
|
+
this.log.info({
|
|
355
|
+
action: "poll.widen-interval",
|
|
356
|
+
message: `widening interval ${this.effectiveIntervalSec}s -> ${required}s per X-Poll-Interval`,
|
|
357
|
+
detail: {
|
|
358
|
+
from: this.effectiveIntervalSec,
|
|
359
|
+
to: required
|
|
153
360
|
}
|
|
154
|
-
return [parsed.data];
|
|
155
361
|
});
|
|
362
|
+
this.effectiveIntervalSec = required;
|
|
363
|
+
if (this.timer !== null) this.scheduleInterval();
|
|
364
|
+
}
|
|
365
|
+
processNotifications(raw) {
|
|
366
|
+
const parsedResults = raw.map((item) => FlumeGitHubNotificationSchema.safeParse(item));
|
|
367
|
+
for (const result of parsedResults) {
|
|
368
|
+
if (result.success) continue;
|
|
369
|
+
this.log.warn({
|
|
370
|
+
action: "parse.skip",
|
|
371
|
+
message: "notification did not match schema",
|
|
372
|
+
detail: { issues: result.error.issues.map((i) => ({
|
|
373
|
+
path: i.path,
|
|
374
|
+
message: i.message
|
|
375
|
+
})) }
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
const notifications = parsedResults.flatMap((r) => r.success ? [r.data] : []);
|
|
379
|
+
const dropped = raw.length - notifications.length;
|
|
156
380
|
if (dropped > 0) this.log.warn({
|
|
157
381
|
action: "parse.summary",
|
|
158
382
|
message: `${dropped}/${raw.length} notifications dropped by schema`
|
|
@@ -160,53 +384,65 @@ var FlumeGitHubPoller = class {
|
|
|
160
384
|
if (!this.bootstrapped) {
|
|
161
385
|
this.bootstrapped = true;
|
|
162
386
|
for (const notification of notifications) this.cache.add(notification.id, notification.updated_at);
|
|
163
|
-
this.
|
|
387
|
+
this.advanceCursor();
|
|
164
388
|
this.log.info({
|
|
165
|
-
action: "bootstrap",
|
|
389
|
+
action: "poller.bootstrap",
|
|
166
390
|
message: `seeded ${notifications.length} existing notifications`
|
|
167
391
|
});
|
|
168
392
|
this.props.onConnected();
|
|
169
393
|
return;
|
|
170
394
|
}
|
|
171
|
-
const fresh =
|
|
172
|
-
|
|
173
|
-
if (this.cache.has(notification.id, notification.updated_at)) continue;
|
|
395
|
+
const fresh = notifications.filter((notification) => {
|
|
396
|
+
if (this.cache.has(notification.id, notification.updated_at)) return false;
|
|
174
397
|
this.cache.add(notification.id, notification.updated_at);
|
|
175
|
-
|
|
176
|
-
}
|
|
398
|
+
return true;
|
|
399
|
+
});
|
|
177
400
|
this.cache.trim();
|
|
178
|
-
this.
|
|
401
|
+
this.advanceCursor();
|
|
179
402
|
if (fresh.length > 0) {
|
|
180
403
|
this.log.info({
|
|
181
404
|
action: "poll.fresh",
|
|
182
|
-
message: `${fresh.length} new notifications
|
|
405
|
+
message: `${fresh.length} new notifications`,
|
|
406
|
+
detail: { count: fresh.length }
|
|
183
407
|
});
|
|
184
408
|
this.props.onNotifications(fresh);
|
|
185
|
-
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
this.log.debug({
|
|
186
412
|
action: "poll.idle",
|
|
187
413
|
message: "0 new notifications"
|
|
188
414
|
});
|
|
189
415
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
416
|
+
advanceCursor() {
|
|
417
|
+
if (this.lastModified !== null) {
|
|
418
|
+
const parsed = new Date(this.lastModified);
|
|
419
|
+
if (!Number.isNaN(parsed.getTime())) {
|
|
420
|
+
const iso = attempt(() => parsed.toISOString());
|
|
421
|
+
if (!(iso instanceof Error)) {
|
|
422
|
+
this.since = iso;
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const nowMs = safeNow({ deps: this.props.deps });
|
|
428
|
+
if (!Number.isFinite(nowMs)) {
|
|
203
429
|
this.log.warn({
|
|
204
|
-
action: "
|
|
205
|
-
message:
|
|
430
|
+
action: "cursor.advance.skip",
|
|
431
|
+
message: "deps.now() returned non-finite, leaving since cursor unchanged"
|
|
206
432
|
});
|
|
207
|
-
|
|
433
|
+
return;
|
|
208
434
|
}
|
|
209
|
-
|
|
435
|
+
const iso = attempt(() => new Date(nowMs).toISOString());
|
|
436
|
+
if (iso instanceof Error) {
|
|
437
|
+
const error = safeNormalizeError({ value: iso });
|
|
438
|
+
this.log.warn({
|
|
439
|
+
action: "cursor.advance.skip",
|
|
440
|
+
message: safeErrorMessage({ error }),
|
|
441
|
+
error
|
|
442
|
+
});
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
this.since = iso;
|
|
210
446
|
}
|
|
211
447
|
};
|
|
212
448
|
//#endregion
|
|
@@ -215,10 +451,24 @@ var FlumeGitHubSource = class {
|
|
|
215
451
|
options;
|
|
216
452
|
name = "github";
|
|
217
453
|
poller = null;
|
|
218
|
-
|
|
454
|
+
handler = null;
|
|
219
455
|
log;
|
|
220
456
|
deps;
|
|
221
457
|
queue = new FlumeSerialQueue();
|
|
458
|
+
signals;
|
|
459
|
+
statusEmitter;
|
|
460
|
+
onSignalAbort = () => {
|
|
461
|
+
safeInvokeCallback({
|
|
462
|
+
fn: () => this.stop(),
|
|
463
|
+
onError: (error) => {
|
|
464
|
+
this.log.error({
|
|
465
|
+
action: "signal.abort.stop.failed",
|
|
466
|
+
message: safeErrorMessage({ error }),
|
|
467
|
+
error
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
};
|
|
222
472
|
constructor(options) {
|
|
223
473
|
this.options = options;
|
|
224
474
|
this.deps = options.deps ?? createFlumeDefaultDeps();
|
|
@@ -227,81 +477,94 @@ var FlumeGitHubSource = class {
|
|
|
227
477
|
handler: options.onLog,
|
|
228
478
|
deps: this.deps
|
|
229
479
|
});
|
|
480
|
+
this.signals = new FlumeSignalRegistry({
|
|
481
|
+
log: this.log,
|
|
482
|
+
onAbort: this.onSignalAbort
|
|
483
|
+
});
|
|
484
|
+
this.statusEmitter = new FlumeStatusEmitter({
|
|
485
|
+
log: this.log,
|
|
486
|
+
onStatus: options.onStatus
|
|
487
|
+
});
|
|
230
488
|
}
|
|
231
|
-
async start(handler) {
|
|
232
|
-
if (this.options.signal?.
|
|
233
|
-
this.
|
|
489
|
+
async start(handler, options) {
|
|
490
|
+
if (this.signals.isAnyAborted(this.options.signal) || this.signals.isAnyAborted(options?.signal)) return new FlumeStartError("GitHub source: signal already aborted");
|
|
491
|
+
this.signals.register(this.options.signal);
|
|
492
|
+
this.signals.register(options?.signal);
|
|
493
|
+
this.handler = handler;
|
|
234
494
|
this.log.info({
|
|
235
|
-
action: "start",
|
|
495
|
+
action: "source.start",
|
|
236
496
|
message: "starting GitHub source"
|
|
237
497
|
});
|
|
238
|
-
this.
|
|
498
|
+
this.statusEmitter.set("connecting");
|
|
239
499
|
this.poller = new FlumeGitHubPoller({
|
|
240
500
|
token: this.options.token,
|
|
241
501
|
interval: this.options.pollInterval ?? 60,
|
|
242
502
|
onLog: this.options.onLog,
|
|
243
503
|
deps: this.deps,
|
|
244
|
-
onNotifications: (notifications) => this.handleNotifications(
|
|
245
|
-
onConnected: () => this.
|
|
246
|
-
onDisconnected: (detail) => this.
|
|
504
|
+
onNotifications: (notifications) => this.handleNotifications(notifications),
|
|
505
|
+
onConnected: () => this.statusEmitter.set("connected"),
|
|
506
|
+
onDisconnected: (detail) => this.statusEmitter.set("disconnected", detail)
|
|
247
507
|
});
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
} catch (error) {
|
|
251
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
508
|
+
const result = await this.poller.start();
|
|
509
|
+
if (result instanceof Error) {
|
|
252
510
|
this.log.error({
|
|
253
|
-
action: "start.failed",
|
|
254
|
-
message:
|
|
255
|
-
error:
|
|
511
|
+
action: "source.start.failed",
|
|
512
|
+
message: safeErrorMessage({ error: result }),
|
|
513
|
+
error: result
|
|
256
514
|
});
|
|
257
|
-
this.
|
|
258
|
-
return
|
|
515
|
+
this.statusEmitter.set("disconnected", result.message);
|
|
516
|
+
return result;
|
|
259
517
|
}
|
|
518
|
+
return null;
|
|
260
519
|
}
|
|
261
520
|
async stop() {
|
|
521
|
+
this.signals.unregisterAll();
|
|
262
522
|
this.log.info({
|
|
263
|
-
action: "stop",
|
|
523
|
+
action: "source.stop",
|
|
264
524
|
message: "stopping GitHub source"
|
|
265
525
|
});
|
|
266
526
|
this.poller?.stop();
|
|
267
|
-
this.poller = null;
|
|
268
527
|
await this.queue.drain();
|
|
269
|
-
this.
|
|
528
|
+
this.poller = null;
|
|
529
|
+
this.handler = null;
|
|
530
|
+
this.statusEmitter.set("disconnected");
|
|
270
531
|
}
|
|
271
532
|
status() {
|
|
272
|
-
return this.
|
|
533
|
+
return this.statusEmitter.value;
|
|
273
534
|
}
|
|
274
|
-
handleNotifications(
|
|
275
|
-
|
|
535
|
+
handleNotifications(notifications) {
|
|
536
|
+
const handler = this.handler;
|
|
537
|
+
if (!handler) return;
|
|
538
|
+
for (const notification of notifications) this.queue.add(async () => {
|
|
276
539
|
const event = {
|
|
277
540
|
source: "github",
|
|
278
541
|
type: "notification",
|
|
279
542
|
data: notification,
|
|
280
|
-
meta:
|
|
281
|
-
receivedAt: this.deps
|
|
543
|
+
meta: this.safeExtractMeta(notification),
|
|
544
|
+
receivedAt: safeNow({ deps: this.deps })
|
|
282
545
|
};
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
action: "handler.error",
|
|
289
|
-
message: "user handler threw",
|
|
290
|
-
error: err instanceof Error ? err : new Error(String(err))
|
|
291
|
-
});
|
|
292
|
-
}
|
|
546
|
+
const r = await attempt(() => Promise.resolve(handler(event)));
|
|
547
|
+
if (r instanceof Error) this.log.error({
|
|
548
|
+
action: "handler.error",
|
|
549
|
+
message: safeErrorMessage({ error: r }),
|
|
550
|
+
error: r
|
|
293
551
|
});
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
setStatus(next, detail) {
|
|
297
|
-
if (this.currentStatus === next) return;
|
|
298
|
-
this.log.info({
|
|
299
|
-
action: "status",
|
|
300
|
-
message: `${this.currentStatus} → ${next}${detail ? ` (${detail})` : ""}`
|
|
301
552
|
});
|
|
302
|
-
|
|
303
|
-
|
|
553
|
+
}
|
|
554
|
+
safeExtractMeta(notification) {
|
|
555
|
+
const result = attempt(() => flumeExtractGitHubMeta(notification));
|
|
556
|
+
if (result instanceof Error) {
|
|
557
|
+
const error = safeNormalizeError({ value: result });
|
|
558
|
+
this.log.warn({
|
|
559
|
+
action: "meta.extract.error",
|
|
560
|
+
message: safeErrorMessage({ error }),
|
|
561
|
+
error,
|
|
562
|
+
detail: { notificationId: notification.id }
|
|
563
|
+
});
|
|
564
|
+
return { event_type: "notification" };
|
|
565
|
+
}
|
|
566
|
+
return result;
|
|
304
567
|
}
|
|
305
568
|
};
|
|
306
569
|
//#endregion
|
|
307
|
-
export {
|
|
570
|
+
export { FlumeGitHubSource, flumeExtractGitHubMeta };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
var FlumeHttpError = class extends Error {
|
|
3
3
|
status;
|
|
4
4
|
constructor(props) {
|
|
5
|
-
super(props.message);
|
|
5
|
+
super(props.message, props.cause === void 0 ? void 0 : { cause: props.cause });
|
|
6
6
|
this.name = "FlumeHttpError";
|
|
7
7
|
this.status = props.status;
|
|
8
8
|
Object.freeze(this);
|