@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/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) {
|
|
129
244
|
this.log.warn({
|
|
130
|
-
action: "http.body",
|
|
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) {
|
|
257
|
+
this.log.warn({
|
|
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
|
|
153
310
|
}
|
|
154
|
-
return [parsed.data];
|
|
155
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
|
|
360
|
+
}
|
|
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,88 +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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
this.options.signal?.addEventListener("abort", () => this.stop(), { once: true });
|
|
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;
|
|
237
494
|
this.log.info({
|
|
238
|
-
action: "start",
|
|
495
|
+
action: "source.start",
|
|
239
496
|
message: "starting GitHub source"
|
|
240
497
|
});
|
|
241
|
-
this.
|
|
498
|
+
this.statusEmitter.set("connecting");
|
|
242
499
|
this.poller = new FlumeGitHubPoller({
|
|
243
500
|
token: this.options.token,
|
|
244
501
|
interval: this.options.pollInterval ?? 60,
|
|
245
502
|
onLog: this.options.onLog,
|
|
246
503
|
deps: this.deps,
|
|
247
|
-
onNotifications: (notifications) => this.handleNotifications(
|
|
248
|
-
onConnected: () => this.
|
|
249
|
-
onDisconnected: (detail) => this.
|
|
504
|
+
onNotifications: (notifications) => this.handleNotifications(notifications),
|
|
505
|
+
onConnected: () => this.statusEmitter.set("connected"),
|
|
506
|
+
onDisconnected: (detail) => this.statusEmitter.set("disconnected", detail)
|
|
250
507
|
});
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
} catch (error) {
|
|
254
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
508
|
+
const result = await this.poller.start();
|
|
509
|
+
if (result instanceof Error) {
|
|
255
510
|
this.log.error({
|
|
256
|
-
action: "start.failed",
|
|
257
|
-
message:
|
|
258
|
-
error:
|
|
511
|
+
action: "source.start.failed",
|
|
512
|
+
message: safeErrorMessage({ error: result }),
|
|
513
|
+
error: result
|
|
259
514
|
});
|
|
260
|
-
this.
|
|
261
|
-
return
|
|
262
|
-
ok: false,
|
|
263
|
-
error: err
|
|
264
|
-
};
|
|
515
|
+
this.statusEmitter.set("disconnected", result.message);
|
|
516
|
+
return result;
|
|
265
517
|
}
|
|
266
|
-
return
|
|
518
|
+
return null;
|
|
267
519
|
}
|
|
268
520
|
async stop() {
|
|
521
|
+
this.signals.unregisterAll();
|
|
269
522
|
this.log.info({
|
|
270
|
-
action: "stop",
|
|
523
|
+
action: "source.stop",
|
|
271
524
|
message: "stopping GitHub source"
|
|
272
525
|
});
|
|
273
526
|
this.poller?.stop();
|
|
274
|
-
this.poller = null;
|
|
275
527
|
await this.queue.drain();
|
|
276
|
-
this.
|
|
528
|
+
this.poller = null;
|
|
529
|
+
this.handler = null;
|
|
530
|
+
this.statusEmitter.set("disconnected");
|
|
277
531
|
}
|
|
278
532
|
status() {
|
|
279
|
-
return this.
|
|
533
|
+
return this.statusEmitter.value;
|
|
280
534
|
}
|
|
281
|
-
handleNotifications(
|
|
282
|
-
|
|
535
|
+
handleNotifications(notifications) {
|
|
536
|
+
const handler = this.handler;
|
|
537
|
+
if (!handler) return;
|
|
538
|
+
for (const notification of notifications) this.queue.add(async () => {
|
|
283
539
|
const event = {
|
|
284
540
|
source: "github",
|
|
285
541
|
type: "notification",
|
|
286
542
|
data: notification,
|
|
287
|
-
meta:
|
|
288
|
-
receivedAt: this.deps
|
|
543
|
+
meta: this.safeExtractMeta(notification),
|
|
544
|
+
receivedAt: safeNow({ deps: this.deps })
|
|
289
545
|
};
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
action: "handler.error",
|
|
296
|
-
message: "user handler threw",
|
|
297
|
-
error: err instanceof Error ? err : new Error(String(err))
|
|
298
|
-
});
|
|
299
|
-
}
|
|
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
|
|
300
551
|
});
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
setStatus(next, detail) {
|
|
304
|
-
if (this.currentStatus === next) return;
|
|
305
|
-
this.log.info({
|
|
306
|
-
action: "status",
|
|
307
|
-
message: `${this.currentStatus} → ${next}${detail ? ` (${detail})` : ""}`
|
|
308
552
|
});
|
|
309
|
-
|
|
310
|
-
|
|
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;
|
|
311
567
|
}
|
|
312
568
|
};
|
|
313
569
|
//#endregion
|
|
314
|
-
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);
|