@interactive-inc/flume 0.9.4 → 0.10.1

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/dist/github.js CHANGED
@@ -1,4 +1,4 @@
1
- import { c as safeNormalizeError, i as safeNow, l as safeErrorMessage, o as FlumeParseError, r as FlumeLogger, s as attempt, t as FlumeSource } from "./flume-source.js";
1
+ import { a as safeNow, c as attempt, i as FlumeLogger, l as safeNormalizeError, s as FlumeParseError, t as FlumeSource, u as safeErrorMessage } from "./flume-source.js";
2
2
  import { t as FlumeHttpError } from "./http-error.js";
3
3
  import { t as safeJsonParse } from "./safe-json-parse.js";
4
4
  import { t as safeReadText } from "./safe-read-text.js";
@@ -38,7 +38,12 @@ var FlumeGitHubSeenCache = class {
38
38
  has(id, updatedAt) {
39
39
  return this.seen.get(id) === updatedAt;
40
40
  }
41
+ /**
42
+ * 既存キーは delete → set で挿入順を末尾へ更新する (LRU)。
43
+ * これをしないと trim() が「長寿命で更新され続ける thread」から先に追い出す
44
+ */
41
45
  add(id, updatedAt) {
46
+ this.seen.delete(id);
42
47
  this.seen.set(id, updatedAt);
43
48
  }
44
49
  trim() {
@@ -59,8 +64,11 @@ var FlumeGitHubSeenCache = class {
59
64
  const NOTIFICATIONS_URL = "https://api.github.com/notifications";
60
65
  const CONSECUTIVE_ERRORS_TO_DISCONNECT = 3;
61
66
  const SEEN_CACHE_MAX = 5e3;
67
+ const MAX_PAGES = 1e3;
68
+ const MAX_TIMER_DELAY_MS = 2147483647;
62
69
  /**
63
70
  * GitHub /notifications を条件付きポーリングする (ETag / Last-Modified)。
71
+ * Link: rel="next" を MAX_PAGES まで辿って全ページを集約し、
64
72
  * 304 / X-Poll-Interval / レート制限を尊重し、stop() / abort() で in-flight 通信を打ち切る
65
73
  */
66
74
  var FlumeGitHubPoller = class {
@@ -69,6 +77,7 @@ var FlumeGitHubPoller = class {
69
77
  cache = new FlumeGitHubSeenCache({ maxSize: SEEN_CACHE_MAX });
70
78
  timer = null;
71
79
  rateLimitTimer = null;
80
+ rateLimitPauseActive = false;
72
81
  since = null;
73
82
  etag = null;
74
83
  lastModified = null;
@@ -76,6 +85,7 @@ var FlumeGitHubPoller = class {
76
85
  isStoppedFlag = false;
77
86
  inFlight = false;
78
87
  consecutiveErrors = 0;
88
+ degraded = false;
79
89
  effectiveIntervalSec;
80
90
  controller = null;
81
91
  constructor(props) {
@@ -108,7 +118,7 @@ var FlumeGitHubPoller = class {
108
118
  const error = await this.poll();
109
119
  if (error) return error;
110
120
  if (this.isStoppedFlag) return null;
111
- if (this.rateLimitTimer !== null) return null;
121
+ if (this.rateLimitPauseActive) return null;
112
122
  this.scheduleInterval();
113
123
  return null;
114
124
  }
@@ -122,6 +132,7 @@ var FlumeGitHubPoller = class {
122
132
  this.controller = null;
123
133
  this.clearTimer();
124
134
  this.clearRateLimitTimer();
135
+ this.rateLimitPauseActive = false;
125
136
  }
126
137
  scheduleInterval() {
127
138
  this.clearTimer();
@@ -134,7 +145,7 @@ var FlumeGitHubPoller = class {
134
145
  error
135
146
  });
136
147
  });
137
- }, this.effectiveIntervalSec * 1e3));
148
+ }, Math.min(this.effectiveIntervalSec * 1e3, MAX_TIMER_DELAY_MS)));
138
149
  if (intervalResult instanceof Error) {
139
150
  this.log.error({
140
151
  action: "poller.interval.schedule.error",
@@ -176,7 +187,114 @@ var FlumeGitHubPoller = class {
176
187
  per_page: "50"
177
188
  });
178
189
  if (this.since) params.set("since", this.since);
179
- const url = `${NOTIFICATIONS_URL}?${params}`;
190
+ const response = await this.fetchPage(`${NOTIFICATIONS_URL}?${params}`, true);
191
+ if (this.isStoppedFlag) return null;
192
+ if (response instanceof Error) return this.recordFailure({
193
+ kind: "network",
194
+ message: response.message,
195
+ cause: response
196
+ });
197
+ this.followPollIntervalHeader(response.headers.get("X-Poll-Interval"));
198
+ if (response.status === 304) {
199
+ this.consecutiveErrors = 0;
200
+ this.log.debug({
201
+ action: "poll.not-modified",
202
+ message: "304 Not Modified"
203
+ });
204
+ this.notifyRecoveredIfDegraded();
205
+ return null;
206
+ }
207
+ if (await this.isRateLimited(response)) {
208
+ this.handleRateLimit(response);
209
+ return null;
210
+ }
211
+ if (!response.ok) {
212
+ const error = new FlumeHttpError({
213
+ message: `HTTP ${response.status}`,
214
+ status: response.status
215
+ });
216
+ return this.recordFailure({
217
+ kind: "http",
218
+ message: safeErrorMessage({ error }),
219
+ error
220
+ });
221
+ }
222
+ const nextEtag = response.headers.get("ETag");
223
+ const nextLastModified = response.headers.get("Last-Modified");
224
+ const rawNotifications = await this.collectPages(response);
225
+ if (!Array.isArray(rawNotifications)) return rawNotifications;
226
+ this.processNotifications(rawNotifications);
227
+ this.consecutiveErrors = 0;
228
+ this.etag = nextEtag;
229
+ this.lastModified = nextLastModified;
230
+ this.advanceCursor();
231
+ this.notifyRecoveredIfDegraded();
232
+ return null;
233
+ }
234
+ /**
235
+ * 先頭ページの body を読み、Link: rel="next" を MAX_PAGES まで辿って
236
+ * 全ページの生 notifications を集約する。2 ページ目以降は無条件リクエスト
237
+ * (If-None-Match / If-Modified-Since なし)。失敗・停止・rate limit 時は
238
+ * Error | null を返し pollOnce がそのまま返す
239
+ */
240
+ async collectPages(firstResponse) {
241
+ const rawNotifications = [];
242
+ let pageResponse = firstResponse;
243
+ let pageCount = 1;
244
+ while (true) {
245
+ const body = await this.readPageBody(pageResponse);
246
+ if (this.isStoppedFlag) return null;
247
+ if (body instanceof Error) return this.recordFailure({
248
+ kind: "http",
249
+ message: body.message,
250
+ error: body
251
+ });
252
+ if (body === null) return null;
253
+ for (const item of body) rawNotifications.push(item);
254
+ const nextUrl = this.findNextPageUrl(pageResponse.headers.get("Link"));
255
+ if (nextUrl === null) return rawNotifications;
256
+ if (pageCount >= MAX_PAGES) {
257
+ const error = new FlumeHttpError({
258
+ message: `pagination exceeded the safety limit of ${MAX_PAGES} pages`,
259
+ status: 0
260
+ });
261
+ return this.recordFailure({
262
+ kind: "http",
263
+ message: error.message,
264
+ error
265
+ });
266
+ }
267
+ const nextResponse = await this.fetchPage(nextUrl, false);
268
+ if (this.isStoppedFlag) return null;
269
+ if (nextResponse instanceof Error) return this.recordFailure({
270
+ kind: "network",
271
+ message: nextResponse.message,
272
+ cause: nextResponse
273
+ });
274
+ if (await this.isRateLimited(nextResponse)) {
275
+ this.handleRateLimit(nextResponse);
276
+ return null;
277
+ }
278
+ if (!nextResponse.ok) {
279
+ const error = new FlumeHttpError({
280
+ message: `HTTP ${nextResponse.status}`,
281
+ status: nextResponse.status
282
+ });
283
+ return this.recordFailure({
284
+ kind: "http",
285
+ message: safeErrorMessage({ error }),
286
+ error
287
+ });
288
+ }
289
+ pageResponse = nextResponse;
290
+ pageCount++;
291
+ }
292
+ }
293
+ /**
294
+ * 1 ページ分を fetch する。条件付きヘッダ (If-None-Match / If-Modified-Since) は
295
+ * 先頭ページのみ付与する
296
+ */
297
+ async fetchPage(url, isFirstPage) {
180
298
  this.log.debug({
181
299
  action: "http.request",
182
300
  message: `GET ${url}`
@@ -186,24 +304,19 @@ var FlumeGitHubPoller = class {
186
304
  Accept: "application/vnd.github+json",
187
305
  "X-GitHub-Api-Version": "2022-11-28"
188
306
  };
189
- if (this.etag) headers["If-None-Match"] = this.etag;
190
- if (this.lastModified) headers["If-Modified-Since"] = this.lastModified;
307
+ if (isFirstPage && this.etag) headers["If-None-Match"] = this.etag;
308
+ if (isFirstPage && this.lastModified) headers["If-Modified-Since"] = this.lastModified;
191
309
  const response = await attempt(() => this.props.deps.fetch(url, {
192
310
  headers,
193
311
  signal: this.controller?.signal
194
312
  }));
195
- if (this.isStoppedFlag) return null;
196
313
  if (response instanceof Error) {
197
314
  this.log.error({
198
315
  action: "http.error",
199
316
  message: safeErrorMessage({ error: response }),
200
317
  error: response
201
318
  });
202
- return this.recordFailure({
203
- kind: "network",
204
- message: response.message,
205
- cause: response
206
- });
319
+ return response;
207
320
  }
208
321
  this.log.debug({
209
322
  action: "http.response",
@@ -213,49 +326,24 @@ var FlumeGitHubPoller = class {
213
326
  url
214
327
  }
215
328
  });
216
- this.maybeWidenInterval(response.headers.get("X-Poll-Interval"));
217
- if (response.status === 304) {
218
- this.consecutiveErrors = 0;
219
- this.log.debug({
220
- action: "poll.not-modified",
221
- message: "304 Not Modified"
222
- });
223
- return null;
224
- }
225
- if (this.isRateLimited(response)) {
226
- this.handleRateLimit(response);
227
- return null;
228
- }
229
- if (!response.ok) {
230
- const error = new FlumeHttpError({
231
- message: `HTTP ${response.status}`,
232
- status: response.status
233
- });
234
- return this.recordFailure({
235
- kind: "http",
236
- message: safeErrorMessage({ error }),
237
- error
238
- });
239
- }
240
- this.consecutiveErrors = 0;
241
- this.etag = response.headers.get("ETag");
242
- this.lastModified = response.headers.get("Last-Modified");
329
+ return response;
330
+ }
331
+ /**
332
+ * body を読んで JSON 配列に解決する。読み取り失敗は FlumeHttpError (呼び出し側で
333
+ * recordFailure する)、JSON 破損・非配列は warn 済みの null (poll ごと破棄) を返す
334
+ */
335
+ async readPageBody(response) {
243
336
  const text = await safeReadText({
244
337
  response,
245
338
  context: "notifications"
246
339
  });
247
- if (this.isStoppedFlag) return null;
248
340
  if (text instanceof FlumeHttpError) {
249
341
  this.log.warn({
250
342
  action: "http.body.read",
251
343
  message: safeErrorMessage({ error: text }),
252
344
  error: text
253
345
  });
254
- return this.recordFailure({
255
- kind: "http",
256
- message: text.message,
257
- error: text
258
- });
346
+ return text;
259
347
  }
260
348
  const json = safeJsonParse(text);
261
349
  if (json instanceof FlumeParseError) {
@@ -274,7 +362,15 @@ var FlumeGitHubPoller = class {
274
362
  });
275
363
  return null;
276
364
  }
277
- this.processNotifications(json);
365
+ return json;
366
+ }
367
+ findNextPageUrl(linkHeader) {
368
+ if (linkHeader === null) return null;
369
+ for (const part of linkHeader.split(",")) {
370
+ if (!part.includes("rel=\"next\"")) continue;
371
+ const url = part.match(/<([^>]+)>/)?.[1];
372
+ if (url !== void 0) return url;
373
+ }
278
374
  return null;
279
375
  }
280
376
  recordFailure(input) {
@@ -290,14 +386,40 @@ var FlumeGitHubPoller = class {
290
386
  error,
291
387
  detail: { consecutiveErrors: this.consecutiveErrors }
292
388
  });
293
- if (this.consecutiveErrors >= CONSECUTIVE_ERRORS_TO_DISCONNECT && !this.isStoppedFlag) this.props.onDisconnected(input.kind === "network" ? "network error" : input.message);
389
+ if (this.consecutiveErrors >= CONSECUTIVE_ERRORS_TO_DISCONNECT && !this.isStoppedFlag) {
390
+ this.degraded = true;
391
+ this.props.onDisconnected(input.kind === "network" ? "network error" : input.message);
392
+ }
294
393
  if (!this.bootstrapped) return error;
295
394
  return null;
296
395
  }
297
- isRateLimited(response) {
396
+ /**
397
+ * onDisconnected 通知後に poll が完全成功したら connected へ戻す。
398
+ * degraded でなければ何もしない (冪等)
399
+ */
400
+ notifyRecoveredIfDegraded() {
401
+ if (!this.degraded) return;
402
+ this.degraded = false;
403
+ this.props.onConnected();
404
+ }
405
+ /**
406
+ * 429 は常に rate limit。403 は Retry-After 付き、X-RateLimit-Remaining が 0、
407
+ * または本文が secondary rate limit / abuse detection を示す場合
408
+ */
409
+ async isRateLimited(response) {
298
410
  if (response.status === 429) return true;
299
- if (response.status === 403 && response.headers.get("X-RateLimit-Remaining") === "0") return true;
300
- return false;
411
+ if (response.status !== 403) return false;
412
+ if (response.headers.get("Retry-After") !== null) return true;
413
+ if (response.headers.get("X-RateLimit-Remaining") === "0") return true;
414
+ const cloned = attempt(() => response.clone());
415
+ if (cloned instanceof Error) return false;
416
+ const body = await safeReadText({
417
+ response: cloned,
418
+ context: "rate limit response"
419
+ });
420
+ if (body instanceof Error) return false;
421
+ const normalized = body.toLowerCase();
422
+ return normalized.includes("secondary rate limit") || normalized.includes("abuse detection");
301
423
  }
302
424
  handleRateLimit(response) {
303
425
  const retryAfter = response.headers.get("Retry-After");
@@ -316,11 +438,21 @@ var FlumeGitHubPoller = class {
316
438
  });
317
439
  this.clearTimer();
318
440
  this.clearRateLimitTimer();
441
+ this.rateLimitPauseActive = true;
319
442
  const timerResult = attempt(() => this.props.deps.setTimeout(() => {
320
443
  this.rateLimitTimer = null;
444
+ this.rateLimitPauseActive = false;
321
445
  if (this.isStoppedFlag) return;
322
446
  this.scheduleInterval();
323
- }, delaySec * 1e3));
447
+ this.poll().catch((err) => {
448
+ const error = safeNormalizeError({ value: err });
449
+ this.log.error({
450
+ action: "poll.unhandled",
451
+ message: safeErrorMessage({ error }),
452
+ error
453
+ });
454
+ });
455
+ }, Math.min(delaySec * 1e3, MAX_TIMER_DELAY_MS)));
324
456
  if (timerResult instanceof Error) {
325
457
  this.log.error({
326
458
  action: "poller.rate-limit.schedule.error",
@@ -328,6 +460,7 @@ var FlumeGitHubPoller = class {
328
460
  error: timerResult
329
461
  });
330
462
  this.rateLimitTimer = null;
463
+ if (!this.isStoppedFlag) this.props.onDisconnected("rate-limit timer scheduling rejected by runtime");
331
464
  } else this.rateLimitTimer = timerResult;
332
465
  }
333
466
  clearTimer() {
@@ -352,19 +485,25 @@ var FlumeGitHubPoller = class {
352
485
  });
353
486
  this.rateLimitTimer = null;
354
487
  }
355
- maybeWidenInterval(headerValue) {
488
+ /**
489
+ * X-Poll-Interval を双方向に追従する。下限はユーザー指定 interval。
490
+ * ヘッダ欠落・非数値は現在の実効値を維持する
491
+ */
492
+ followPollIntervalHeader(headerValue) {
356
493
  if (headerValue === null) return;
357
- const required = Number.parseInt(headerValue, 10);
358
- if (!Number.isFinite(required) || required <= this.effectiveIntervalSec) return;
494
+ const headerSec = Number.parseInt(headerValue, 10);
495
+ if (!Number.isFinite(headerSec)) return;
496
+ const nextSec = Math.min(Math.max(this.props.interval, headerSec), Math.floor(MAX_TIMER_DELAY_MS / 1e3));
497
+ if (nextSec === this.effectiveIntervalSec) return;
359
498
  this.log.info({
360
- action: "poll.widen-interval",
361
- message: `widening interval ${this.effectiveIntervalSec}s -> ${required}s per X-Poll-Interval`,
499
+ action: "poll.interval.follow",
500
+ message: `adjusting interval ${this.effectiveIntervalSec}s -> ${nextSec}s per X-Poll-Interval`,
362
501
  detail: {
363
502
  from: this.effectiveIntervalSec,
364
- to: required
503
+ to: nextSec
365
504
  }
366
505
  });
367
- this.effectiveIntervalSec = required;
506
+ this.effectiveIntervalSec = nextSec;
368
507
  if (this.timer !== null) this.scheduleInterval();
369
508
  }
370
509
  processNotifications(raw) {
@@ -389,11 +528,11 @@ var FlumeGitHubPoller = class {
389
528
  if (!this.bootstrapped) {
390
529
  this.bootstrapped = true;
391
530
  for (const notification of notifications) this.cache.add(notification.id, notification.updated_at);
392
- this.advanceCursor();
393
531
  this.log.info({
394
532
  action: "poller.bootstrap",
395
533
  message: `seeded ${notifications.length} existing notifications`
396
534
  });
535
+ this.degraded = false;
397
536
  this.props.onConnected();
398
537
  return;
399
538
  }
@@ -403,7 +542,6 @@ var FlumeGitHubPoller = class {
403
542
  return true;
404
543
  });
405
544
  this.cache.trim();
406
- this.advanceCursor();
407
545
  if (fresh.length > 0) {
408
546
  this.log.info({
409
547
  action: "poll.fresh",
@@ -452,6 +590,8 @@ var FlumeGitHubPoller = class {
452
590
  };
453
591
  //#endregion
454
592
  //#region lib/github/github-source.ts
593
+ const DEFAULT_POLL_INTERVAL_SEC = 60;
594
+ const MAX_POLL_INTERVAL_SEC = Math.floor(2147483647 / 1e3);
455
595
  var FlumeGitHubSource = class extends FlumeSource {
456
596
  options;
457
597
  name = "github";
@@ -464,7 +604,7 @@ var FlumeGitHubSource = class extends FlumeSource {
464
604
  this.setStatus("connecting");
465
605
  this.poller = new FlumeGitHubPoller({
466
606
  token: this.options.token,
467
- interval: this.options.pollInterval ?? 60,
607
+ interval: this.getPollIntervalSec(),
468
608
  onLog: ctx.log.handler,
469
609
  deps: ctx.deps,
470
610
  onNotifications: (notifications) => this.handleNotifications(ctx, notifications),
@@ -487,6 +627,16 @@ var FlumeGitHubSource = class extends FlumeSource {
487
627
  this.poller?.stop();
488
628
  this.poller = null;
489
629
  }
630
+ /**
631
+ * pollInterval が非数値・非有限・0 以下の場合は既定値へフォールバックする
632
+ */
633
+ getPollIntervalSec() {
634
+ const requested = this.options.pollInterval;
635
+ if (typeof requested !== "number") return DEFAULT_POLL_INTERVAL_SEC;
636
+ if (!Number.isFinite(requested)) return DEFAULT_POLL_INTERVAL_SEC;
637
+ if (requested <= 0) return DEFAULT_POLL_INTERVAL_SEC;
638
+ return Math.min(requested, MAX_POLL_INTERVAL_SEC);
639
+ }
490
640
  handleNotifications(ctx, notifications) {
491
641
  for (const notification of notifications) this.emit({
492
642
  source: "github",
@@ -1,10 +1,14 @@
1
1
  //#region lib/errors/http-error.ts
2
2
  var FlumeHttpError = class extends Error {
3
3
  status;
4
+ code;
5
+ retryAfterMs;
4
6
  constructor(props) {
5
7
  super(props.message, props.cause === void 0 ? void 0 : { cause: props.cause });
6
8
  this.name = "FlumeHttpError";
7
9
  this.status = props.status;
10
+ this.code = props.code ?? null;
11
+ this.retryAfterMs = props.retryAfterMs ?? null;
8
12
  Object.freeze(this);
9
13
  }
10
14
  };