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