@interactive-inc/flume 0.4.0 → 0.9.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.
@@ -0,0 +1,344 @@
1
+ //#region lib/utils/safe-error-message.ts
2
+ /**
3
+ * 任意の値から人が読めるメッセージ文字列を取り出す。
4
+ * `Error.message` getter / `Symbol.toPrimitive` / `toString` / `valueOf` が throw しても固定文字列に fallback。
5
+ * 自身は決して throw しない
6
+ */
7
+ function safeErrorMessage(props) {
8
+ if (props.error instanceof Error) try {
9
+ const message = props.error.message;
10
+ if (typeof message === "string") return message;
11
+ return "<non-string error message>";
12
+ } catch {
13
+ return "<unreadable error message>";
14
+ }
15
+ try {
16
+ return String(props.error);
17
+ } catch {
18
+ return "<unprintable error>";
19
+ }
20
+ }
21
+ //#endregion
22
+ //#region lib/utils/safe-normalize-error.ts
23
+ /**
24
+ * 任意の値を `Error` インスタンスへ正規化する。すでに Error ならそのまま返し、
25
+ * それ以外は `safeErrorMessage` で安全な文字列化を経由して new Error する。
26
+ * Error コンストラクタ自体が throw する病的環境でも fallback を返し、決して throw しない
27
+ */
28
+ function safeNormalizeError(props) {
29
+ if (props.value instanceof Error) return props.value;
30
+ const message = safeErrorMessage({ error: props.value });
31
+ try {
32
+ return new Error(message);
33
+ } catch {
34
+ try {
35
+ return /* @__PURE__ */ new Error("unknown error");
36
+ } catch {
37
+ const fallback = Object.create(Error.prototype);
38
+ fallback.name = "Error";
39
+ fallback.message = "unknown error";
40
+ return fallback;
41
+ }
42
+ }
43
+ }
44
+ //#endregion
45
+ //#region lib/utils/attempt.ts
46
+ function attempt(fn) {
47
+ try {
48
+ const result = fn();
49
+ if (result instanceof Promise) return result.catch((err) => safeNormalizeError({ value: err }));
50
+ return result;
51
+ } catch (err) {
52
+ return safeNormalizeError({ value: err });
53
+ }
54
+ }
55
+ //#endregion
56
+ //#region lib/errors/parse-error.ts
57
+ var FlumeParseError = class extends Error {
58
+ constructor(message, options) {
59
+ super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
60
+ this.name = "FlumeParseError";
61
+ Object.freeze(this);
62
+ }
63
+ };
64
+ //#endregion
65
+ //#region lib/errors/start-error.ts
66
+ var FlumeStartError = class extends Error {
67
+ constructor(message, options) {
68
+ super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
69
+ this.name = "FlumeStartError";
70
+ Object.freeze(this);
71
+ }
72
+ };
73
+ //#endregion
74
+ //#region lib/utils/safe-now.ts
75
+ /**
76
+ * `deps.now()` を保護する。throw / 非数値が返った場合は 0 を返す。
77
+ * IO 境界のため呼び出し側はこの戻り値を信頼できる
78
+ */
79
+ function safeNow(props) {
80
+ try {
81
+ const value = props.deps.now();
82
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
83
+ return value;
84
+ } catch {
85
+ return 0;
86
+ }
87
+ }
88
+ //#endregion
89
+ //#region lib/logger.ts
90
+ /**
91
+ * 構造化ログを onLog に流す。handler が throw / reject してもループは継続する
92
+ */
93
+ var FlumeLogger = class FlumeLogger {
94
+ props;
95
+ constructor(props) {
96
+ this.props = props;
97
+ Object.freeze(this);
98
+ }
99
+ debug(entry) {
100
+ this.emit("debug", entry);
101
+ }
102
+ info(entry) {
103
+ this.emit("info", entry);
104
+ }
105
+ warn(entry) {
106
+ this.emit("warn", entry);
107
+ }
108
+ error(entry) {
109
+ this.emit("error", entry);
110
+ }
111
+ get handler() {
112
+ return this.props.handler;
113
+ }
114
+ child(source) {
115
+ return new FlumeLogger({
116
+ source,
117
+ handler: this.props.handler,
118
+ deps: this.props.deps
119
+ });
120
+ }
121
+ emit(level, input) {
122
+ const handler = this.props.handler;
123
+ if (!handler) return;
124
+ const log = {
125
+ level,
126
+ source: this.props.source,
127
+ action: input.action,
128
+ message: input.message,
129
+ timestamp: safeNow({ deps: this.props.deps }),
130
+ error: input.error,
131
+ detail: input.detail
132
+ };
133
+ try {
134
+ Promise.resolve(handler(log)).catch(() => {});
135
+ } catch {}
136
+ }
137
+ };
138
+ //#endregion
139
+ //#region lib/utils/safe-invoke-callback.ts
140
+ /**
141
+ * fire-and-forget でユーザーコールバックを呼び出す。sync throw と async reject のどちらも
142
+ * `onError(Error)` に正規化して通知。`onError` 自身が throw しても外に漏らさない。
143
+ * 戻り値を持たない fire-and-forget 専用のため log/出力先には依存しない (caller が onError で決める)
144
+ */
145
+ function safeInvokeCallback(props) {
146
+ try {
147
+ Promise.resolve(props.fn()).catch((err) => {
148
+ try {
149
+ props.onError(safeNormalizeError({ value: err }));
150
+ } catch {}
151
+ }).catch(() => {});
152
+ } catch (err) {
153
+ try {
154
+ props.onError(safeNormalizeError({ value: err }));
155
+ } catch {}
156
+ }
157
+ }
158
+ //#endregion
159
+ //#region lib/source-helpers/flume-status-emitter.ts
160
+ /**
161
+ * Source の `currentStatus` と `onStatus` 通知を集約する。
162
+ * 同一 (status, detail) の連続遷移は冪等に握り潰し、ユーザーコールバックは `safeInvokeCallback`
163
+ * 経由で例外を隔離する
164
+ */
165
+ var FlumeStatusEmitter = class {
166
+ props;
167
+ currentStatus = "disconnected";
168
+ currentDetail = null;
169
+ constructor(props) {
170
+ this.props = props;
171
+ }
172
+ get value() {
173
+ return this.currentStatus;
174
+ }
175
+ set(next, detail) {
176
+ const normalizedDetail = detail ?? null;
177
+ if (this.currentStatus === next && this.currentDetail === normalizedDetail) return;
178
+ const prev = this.currentStatus;
179
+ const suffix = detail ? ` (${detail})` : "";
180
+ this.props.log.info({
181
+ action: "status",
182
+ message: `${prev} → ${next}${suffix}`,
183
+ detail: {
184
+ from: prev,
185
+ to: next,
186
+ reason: normalizedDetail
187
+ }
188
+ });
189
+ this.currentStatus = next;
190
+ this.currentDetail = normalizedDetail;
191
+ const onStatus = this.props.onStatus;
192
+ if (!onStatus) return;
193
+ safeInvokeCallback({
194
+ fn: detail !== void 0 ? () => onStatus(next, detail) : () => onStatus(next),
195
+ onError: (error) => {
196
+ this.props.log.error({
197
+ action: "onStatus.error",
198
+ message: safeErrorMessage({ error }),
199
+ error
200
+ });
201
+ }
202
+ });
203
+ }
204
+ };
205
+ //#endregion
206
+ //#region lib/utils/serial-queue.ts
207
+ /**
208
+ * 投入順を保ったまま task を直列実行する。各 task は前の完了を待ってから走る。
209
+ * task が throw しても後続には伝播しない (キュー自体は止まらない)。
210
+ * maxDepth を超えた場合は新規 task を drop し onOverflow に通知。
211
+ * cancel() 後の add() は no-op となり drain() は即時 resolve する
212
+ */
213
+ var FlumeSerialQueue = class {
214
+ props;
215
+ chain = Promise.resolve();
216
+ depth = 0;
217
+ cancelled = false;
218
+ constructor(props = {}) {
219
+ this.props = props;
220
+ }
221
+ add(task) {
222
+ if (this.cancelled) return;
223
+ if (this.props.maxDepth !== void 0 && this.depth >= this.props.maxDepth) {
224
+ this.props.onOverflow?.({
225
+ dropped: 1,
226
+ depth: this.depth
227
+ });
228
+ return;
229
+ }
230
+ this.depth++;
231
+ this.chain = this.chain.then(async () => {
232
+ try {
233
+ await task();
234
+ } catch {} finally {
235
+ this.depth--;
236
+ }
237
+ });
238
+ }
239
+ async drain() {
240
+ await this.chain;
241
+ }
242
+ cancel() {
243
+ this.cancelled = true;
244
+ this.depth = 0;
245
+ this.chain = Promise.resolve();
246
+ }
247
+ size() {
248
+ return this.depth;
249
+ }
250
+ isCancelled() {
251
+ return this.cancelled;
252
+ }
253
+ };
254
+ //#endregion
255
+ //#region lib/flume-source.ts
256
+ /**
257
+ * 全 Source の基底クラス。protocol 固有のロジック (`connect` / `disconnect`) のみ
258
+ * subclass に実装させ、queue / status / handler 安全呼び出しといった共通の
259
+ * cross-cutting concern は base が引き受ける。Flume 側で全 source に注入される
260
+ * `FlumeSourceStartContext` (handler / log / deps / onStatus / reconnect) を
261
+ * `start()` で受け取り、subclass の `connect(ctx)` に手渡す。
262
+ *
263
+ * subclass のテンプレート:
264
+ *
265
+ * ```ts
266
+ * export class MySource extends FlumeSource {
267
+ * readonly name = "my-source"
268
+ *
269
+ * constructor(private readonly options: { apiKey: string }) {
270
+ * super()
271
+ * }
272
+ *
273
+ * protected async connect(ctx: FlumeSourceStartContext): Promise<Error | null> {
274
+ * // 接続して onEvent で this.emit({...}) / 状態遷移で this.setStatus(...)
275
+ * return null
276
+ * }
277
+ *
278
+ * protected disconnect(): void { ... }
279
+ * }
280
+ * ```
281
+ */
282
+ var FlumeSource = class {
283
+ consumed = false;
284
+ stopped = false;
285
+ ctx = null;
286
+ statusEmitter = null;
287
+ queue = new FlumeSerialQueue();
288
+ async start(ctx) {
289
+ if (this.consumed) return new FlumeStartError(`${this.name}: already started`);
290
+ this.consumed = true;
291
+ this.ctx = ctx;
292
+ this.statusEmitter = new FlumeStatusEmitter({
293
+ log: ctx.log,
294
+ onStatus: ctx.onStatus
295
+ });
296
+ return await this.connect(ctx);
297
+ }
298
+ async stop() {
299
+ if (this.stopped) return;
300
+ this.stopped = true;
301
+ try {
302
+ await this.disconnect();
303
+ } finally {
304
+ await this.queue.drain();
305
+ this.statusEmitter?.set("disconnected");
306
+ this.ctx = null;
307
+ }
308
+ }
309
+ status() {
310
+ return this.statusEmitter?.value ?? "disconnected";
311
+ }
312
+ /**
313
+ * subclass が受信した protocol イベントを `FlumeEvent` として handler へ流す。
314
+ * handler の throw / async reject は queue 内で catch + log し、後続を止めない
315
+ */
316
+ emit(event) {
317
+ const ctx = this.ctx;
318
+ if (!ctx) return;
319
+ this.queue.add(async () => {
320
+ const result = await attempt(() => Promise.resolve(ctx.onEvent(event)));
321
+ if (result instanceof Error) ctx.log.error({
322
+ action: "onEvent.error",
323
+ message: safeErrorMessage({ error: result }),
324
+ error: result
325
+ });
326
+ });
327
+ }
328
+ /**
329
+ * subclass が protocol 状態遷移をユーザーに通知する。同一 (status, detail) の連続は冪等
330
+ */
331
+ setStatus(status, detail) {
332
+ this.statusEmitter?.set(status, detail);
333
+ }
334
+ /** subclass が現在の status を読みたい場合 */
335
+ get currentStatus() {
336
+ return this.statusEmitter?.value ?? "disconnected";
337
+ }
338
+ /** subclass が start ctx を再参照したい場合 (stop 後は null) */
339
+ get context() {
340
+ return this.ctx;
341
+ }
342
+ };
343
+ //#endregion
344
+ export { FlumeStartError as a, safeNormalizeError as c, safeNow as i, safeErrorMessage as l, safeInvokeCallback as n, FlumeParseError as o, FlumeLogger as r, attempt as s, FlumeSource as t };
package/dist/github.d.ts CHANGED
@@ -1,21 +1,13 @@
1
- import { C as FlumeSourceStartOptions, T as FlumeStatus, c as FlumeHandler, o as FlumeGitHubNotification, s as FlumeGitHubSourceOptions } from "./types-D-tO-Mh2.js";
1
+ import { l as FlumeGitHubNotification, t as FlumeSource, u as FlumeGitHubSourceOptions, w as FlumeSourceStartContext } from "./flume-source.js";
2
2
 
3
3
  //#region lib/github/github-source.d.ts
4
- declare class FlumeGitHubSource {
4
+ declare class FlumeGitHubSource extends FlumeSource {
5
5
  private readonly options;
6
6
  readonly name: "github";
7
7
  private poller;
8
- private handler;
9
- private readonly log;
10
- private readonly deps;
11
- private readonly queue;
12
- private readonly signals;
13
- private readonly statusEmitter;
14
- private readonly onSignalAbort;
15
8
  constructor(options: FlumeGitHubSourceOptions);
16
- start(handler: FlumeHandler, options?: FlumeSourceStartOptions): Promise<Error | null>;
17
- stop(): Promise<void>;
18
- status(): FlumeStatus;
9
+ protected connect(ctx: FlumeSourceStartContext): Promise<Error | null>;
10
+ protected disconnect(): void;
19
11
  private handleNotifications;
20
12
  private safeExtractMeta;
21
13
  }
package/dist/github.js CHANGED
@@ -1,7 +1,7 @@
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";
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";
3
+ import { t as safeJsonParse } from "./safe-json-parse.js";
4
+ import { t as safeReadText } from "./safe-read-text.js";
5
5
  import { z } from "zod/v4";
6
6
  //#region lib/github/extract-github-meta.ts
7
7
  function flumeExtractGitHubMeta(notification) {
@@ -43,8 +43,12 @@ var FlumeGitHubSeenCache = class {
43
43
  }
44
44
  trim() {
45
45
  if (this.seen.size <= this.props.maxSize) return;
46
- const entries = [...this.seen.entries()];
47
- this.seen = new Map(entries.slice(entries.length - this.props.maxSize));
46
+ let removeCount = this.seen.size - this.props.maxSize;
47
+ for (const id of this.seen.keys()) {
48
+ if (removeCount <= 0) break;
49
+ this.seen.delete(id);
50
+ removeCount--;
51
+ }
48
52
  }
49
53
  get size() {
50
54
  return this.seen.size;
@@ -104,6 +108,7 @@ var FlumeGitHubPoller = class {
104
108
  const error = await this.poll();
105
109
  if (error) return error;
106
110
  if (this.isStoppedFlag) return null;
111
+ if (this.rateLimitTimer !== null) return null;
107
112
  this.scheduleInterval();
108
113
  return null;
109
114
  }
@@ -128,7 +133,7 @@ var FlumeGitHubPoller = class {
128
133
  message: safeErrorMessage({ error }),
129
134
  error
130
135
  });
131
- }).catch(() => {});
136
+ });
132
137
  }, this.effectiveIntervalSec * 1e3));
133
138
  if (intervalResult instanceof Error) {
134
139
  this.log.error({
@@ -447,115 +452,55 @@ var FlumeGitHubPoller = class {
447
452
  };
448
453
  //#endregion
449
454
  //#region lib/github/github-source.ts
450
- var FlumeGitHubSource = class {
455
+ var FlumeGitHubSource = class extends FlumeSource {
451
456
  options;
452
457
  name = "github";
453
458
  poller = null;
454
- handler = null;
455
- log;
456
- deps;
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
- };
472
459
  constructor(options) {
460
+ super();
473
461
  this.options = options;
474
- this.deps = options.deps ?? createFlumeDefaultDeps();
475
- this.log = new FlumeLogger({
476
- source: "github",
477
- handler: options.onLog,
478
- deps: this.deps
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
- });
488
462
  }
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;
494
- this.log.info({
495
- action: "source.start",
496
- message: "starting GitHub source"
497
- });
498
- this.statusEmitter.set("connecting");
463
+ async connect(ctx) {
464
+ this.setStatus("connecting");
499
465
  this.poller = new FlumeGitHubPoller({
500
466
  token: this.options.token,
501
467
  interval: this.options.pollInterval ?? 60,
502
- onLog: this.options.onLog,
503
- deps: this.deps,
504
- onNotifications: (notifications) => this.handleNotifications(notifications),
505
- onConnected: () => this.statusEmitter.set("connected"),
506
- onDisconnected: (detail) => this.statusEmitter.set("disconnected", detail)
468
+ onLog: ctx.log.handler,
469
+ deps: ctx.deps,
470
+ onNotifications: (notifications) => this.handleNotifications(ctx, notifications),
471
+ onConnected: () => this.setStatus("connected"),
472
+ onDisconnected: (detail) => this.setStatus("disconnected", detail)
507
473
  });
508
474
  const result = await this.poller.start();
509
475
  if (result instanceof Error) {
510
- this.log.error({
476
+ ctx.log.error({
511
477
  action: "source.start.failed",
512
478
  message: safeErrorMessage({ error: result }),
513
479
  error: result
514
480
  });
515
- this.statusEmitter.set("disconnected", result.message);
481
+ this.setStatus("disconnected", result.message);
516
482
  return result;
517
483
  }
518
484
  return null;
519
485
  }
520
- async stop() {
521
- this.signals.unregisterAll();
522
- this.log.info({
523
- action: "source.stop",
524
- message: "stopping GitHub source"
525
- });
486
+ disconnect() {
526
487
  this.poller?.stop();
527
- await this.queue.drain();
528
488
  this.poller = null;
529
- this.handler = null;
530
- this.statusEmitter.set("disconnected");
531
- }
532
- status() {
533
- return this.statusEmitter.value;
534
489
  }
535
- handleNotifications(notifications) {
536
- const handler = this.handler;
537
- if (!handler) return;
538
- for (const notification of notifications) this.queue.add(async () => {
539
- const event = {
540
- source: "github",
541
- type: "notification",
542
- data: notification,
543
- meta: this.safeExtractMeta(notification),
544
- receivedAt: safeNow({ deps: this.deps })
545
- };
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
551
- });
490
+ handleNotifications(ctx, notifications) {
491
+ for (const notification of notifications) this.emit({
492
+ source: "github",
493
+ type: "notification",
494
+ data: notification,
495
+ meta: this.safeExtractMeta(ctx, notification),
496
+ receivedAt: safeNow({ deps: ctx.deps })
552
497
  });
553
498
  }
554
- safeExtractMeta(notification) {
499
+ safeExtractMeta(ctx, notification) {
555
500
  const result = attempt(() => flumeExtractGitHubMeta(notification));
556
501
  if (result instanceof Error) {
557
502
  const error = safeNormalizeError({ value: result });
558
- this.log.warn({
503
+ ctx.log.warn({
559
504
  action: "meta.extract.error",
560
505
  message: safeErrorMessage({ error }),
561
506
  error,