@interactive-inc/flume 0.3.0 → 0.6.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,9 +1,10 @@
1
- import { n as createFlumeDefaultDeps, t as FlumeLogger } from "./logger-CpGB9WO_.js";
2
- import { t as FlumeSerialQueue } from "./serial-queue-ExmlnpzQ.js";
3
- import { t as safeFetch } from "./safe-fetch-30ZzOKHL.js";
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-DUvt9aJt.js";
2
+ import { t as FlumeHttpError } from "./http-error-CPSKoSie.js";
3
+ import { t as safeJsonParse } from "./safe-json-parse-CfJjt-RY.js";
4
+ import { t as safeReadText } from "./safe-read-text-JQd_5vbd.js";
4
5
  import { z } from "zod/v4";
5
6
  //#region lib/github/extract-github-meta.ts
6
- function extractGitHubMeta(notification) {
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: 5e3 });
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
- stopped = false;
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.stopped = false;
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.props.interval}s`
101
+ action: "poller.start",
102
+ message: `polling every ${this.effectiveIntervalSec}s`
76
103
  });
77
- await this.poll();
78
- this.timer = this.props.deps.setInterval(() => {
79
- this.poll().catch((err) => {
80
- this.log.error({
81
- action: "poll.unhandled",
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.stopped = true;
94
- if (this.timer !== null) {
95
- this.props.deps.clearInterval(this.timer);
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
- const params = new URLSearchParams({ all: "false" });
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 = `https://api.github.com/notifications?${params}`;
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 response = await this.safeFetch(url);
108
- if (response instanceof Error) return;
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
- this.consecutiveErrors++;
119
- this.log.error({
120
- action: "http.error",
121
- message: `HTTP ${response.status} (consecutive=${this.consecutiveErrors})`
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
- const body = await response.json();
128
- if (!Array.isArray(body)) {
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) {
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)) {
129
265
  this.log.warn({
130
- action: "http.body",
266
+ action: "http.body.shape",
131
267
  message: "response body is not an array, dropping",
132
- detail: { bodyType: typeof body }
268
+ detail: { bodyType: typeof json }
133
269
  });
134
- return;
270
+ return null;
135
271
  }
136
- this.processNotifications(body);
272
+ this.processNotifications(json);
273
+ return null;
137
274
  }
138
- processNotifications(raw) {
139
- let dropped = 0;
140
- const notifications = raw.flatMap((item) => {
141
- const parsed = FlumeGitHubNotificationSchema.safeParse(item);
142
- if (!parsed.success) {
143
- dropped++;
144
- this.log.warn({
145
- action: "parse.skip",
146
- message: "notification did not match schema",
147
- detail: { issues: parsed.error.issues.map((i) => ({
148
- path: i.path,
149
- message: i.message
150
- })) }
151
- });
152
- return [];
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,155 +384,127 @@ 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.since = new Date(this.props.deps.now()).toISOString();
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
- for (const notification of notifications) {
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
- fresh.push(notification);
176
- }
398
+ return true;
399
+ });
177
400
  this.cache.trim();
178
- this.since = new Date(this.props.deps.now()).toISOString();
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
- } else this.log.debug({
409
+ return;
410
+ }
411
+ this.log.debug({
186
412
  action: "poll.idle",
187
413
  message: "0 new notifications"
188
414
  });
189
415
  }
190
- async safeFetch(url) {
191
- const result = await safeFetch({
192
- fetch: this.props.deps.fetch,
193
- url,
194
- init: { headers: {
195
- Authorization: `Bearer ${this.props.token}`,
196
- Accept: "application/vnd.github+json",
197
- "X-GitHub-Api-Version": "2022-11-28"
198
- } },
199
- log: this.log
200
- });
201
- if (result instanceof Error) {
202
- this.consecutiveErrors++;
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: "http.error",
205
- message: `consecutive=${this.consecutiveErrors}`
430
+ action: "cursor.advance.skip",
431
+ message: "deps.now() returned non-finite, leaving since cursor unchanged"
206
432
  });
207
- if (this.consecutiveErrors >= 3) this.props.onDisconnected("network error");
433
+ return;
208
434
  }
209
- return result;
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
213
449
  //#region lib/github/github-source.ts
214
- var FlumeGitHubSource = class {
450
+ var FlumeGitHubSource = class extends FlumeSource {
215
451
  options;
216
452
  name = "github";
217
453
  poller = null;
218
- currentStatus = "disconnected";
219
- log;
220
- deps;
221
- queue = new FlumeSerialQueue();
222
454
  constructor(options) {
455
+ super();
223
456
  this.options = options;
224
- this.deps = options.deps ?? createFlumeDefaultDeps();
225
- this.log = new FlumeLogger({
226
- source: "github",
227
- handler: options.onLog,
228
- deps: this.deps
229
- });
230
457
  }
231
- async start(handler) {
232
- if (this.options.signal?.aborted) return {
233
- ok: false,
234
- error: /* @__PURE__ */ new Error("GitHub source: signal already aborted")
235
- };
236
- this.options.signal?.addEventListener("abort", () => this.stop(), { once: true });
237
- this.log.info({
238
- action: "start",
239
- message: "starting GitHub source"
240
- });
458
+ async connect(ctx) {
241
459
  this.setStatus("connecting");
242
460
  this.poller = new FlumeGitHubPoller({
243
461
  token: this.options.token,
244
462
  interval: this.options.pollInterval ?? 60,
245
- onLog: this.options.onLog,
246
- deps: this.deps,
247
- onNotifications: (notifications) => this.handleNotifications(handler, notifications),
463
+ onLog: ctx.log.handler,
464
+ deps: ctx.deps,
465
+ onNotifications: (notifications) => this.handleNotifications(ctx, notifications),
248
466
  onConnected: () => this.setStatus("connected"),
249
467
  onDisconnected: (detail) => this.setStatus("disconnected", detail)
250
468
  });
251
- try {
252
- await this.poller.start();
253
- } catch (error) {
254
- const err = error instanceof Error ? error : new Error(String(error));
255
- this.log.error({
256
- action: "start.failed",
257
- message: err.message,
258
- error: err
469
+ const result = await this.poller.start();
470
+ if (result instanceof Error) {
471
+ ctx.log.error({
472
+ action: "source.start.failed",
473
+ message: safeErrorMessage({ error: result }),
474
+ error: result
259
475
  });
260
- this.setStatus("disconnected");
261
- return {
262
- ok: false,
263
- error: err
264
- };
476
+ this.setStatus("disconnected", result.message);
477
+ return result;
265
478
  }
266
- return { ok: true };
479
+ return null;
267
480
  }
268
- async stop() {
269
- this.log.info({
270
- action: "stop",
271
- message: "stopping GitHub source"
272
- });
481
+ disconnect() {
273
482
  this.poller?.stop();
274
483
  this.poller = null;
275
- await this.queue.drain();
276
- this.setStatus("disconnected");
277
484
  }
278
- status() {
279
- return this.currentStatus;
485
+ handleNotifications(ctx, notifications) {
486
+ for (const notification of notifications) this.emit({
487
+ source: "github",
488
+ type: "notification",
489
+ data: notification,
490
+ meta: this.safeExtractMeta(ctx, notification),
491
+ receivedAt: safeNow({ deps: ctx.deps })
492
+ });
280
493
  }
281
- handleNotifications(handler, notifications) {
282
- for (const notification of notifications) {
283
- const event = {
284
- source: "github",
285
- type: "notification",
286
- data: notification,
287
- meta: extractGitHubMeta(notification),
288
- receivedAt: this.deps.now()
289
- };
290
- this.queue.add(async () => {
291
- try {
292
- await handler(event);
293
- } catch (err) {
294
- this.log.error({
295
- action: "handler.error",
296
- message: "user handler threw",
297
- error: err instanceof Error ? err : new Error(String(err))
298
- });
299
- }
494
+ safeExtractMeta(ctx, notification) {
495
+ const result = attempt(() => flumeExtractGitHubMeta(notification));
496
+ if (result instanceof Error) {
497
+ const error = safeNormalizeError({ value: result });
498
+ ctx.log.warn({
499
+ action: "meta.extract.error",
500
+ message: safeErrorMessage({ error }),
501
+ error,
502
+ detail: { notificationId: notification.id }
300
503
  });
504
+ return { event_type: "notification" };
301
505
  }
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
- });
309
- this.currentStatus = next;
310
- this.options.onStatus?.(next, detail);
506
+ return result;
311
507
  }
312
508
  };
313
509
  //#endregion
314
- export { FlumeGitHubNotificationSchema, FlumeGitHubPoller, FlumeGitHubSeenCache, FlumeGitHubSource, extractGitHubMeta };
510
+ 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);