@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.
package/dist/slack.js CHANGED
@@ -1,9 +1,10 @@
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 FlumeConnectionError } from "./connection-error-HUO3PC3G.js";
3
- import { t as FlumeHttpError } from "./http-error-CPSKoSie.js";
4
- import { a as FlumeReconnector, i as resolveFlumeReconnectConfig, n as isRecord, r as scheduleFlumeReconnect, t as safeStringify } from "./safe-stringify-BWS-uXZP.js";
5
- import { i as safeJsonParse, n as FlumeStatusEmitter, r as FlumeSignalRegistry, t as FlumeSerialQueue } from "./serial-queue-B9LoBc64.js";
6
- import { t as safeReadText } from "./safe-read-text-DgrJ4Uhl.js";
1
+ import { a as FlumeStartError, 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 FlumeConnectionError } from "./connection-error.js";
3
+ import { t as FlumeHttpError } from "./http-error.js";
4
+ import { n as scheduleFlumeReconnect, r as FlumeReconnector, t as safeStringify } from "./safe-stringify.js";
5
+ import { t as isRecord } from "./is-record.js";
6
+ import { t as safeJsonParse } from "./safe-json-parse.js";
7
+ import { t as safeReadText } from "./safe-read-text.js";
7
8
  import { z } from "zod/v4";
8
9
  //#region lib/slack/extract-slack-meta.ts
9
10
  function flumeExtractSlackMeta(envelope) {
@@ -45,8 +46,12 @@ var FlumeSlackSeenCache = class {
45
46
  const cutoff = safeNow({ deps: this.props.deps }) - this.props.ttlMs;
46
47
  for (const [id, timestamp] of this.seen) if (timestamp < cutoff) this.seen.delete(id);
47
48
  if (this.seen.size <= this.props.maxSize) return;
48
- const entries = [...this.seen.entries()];
49
- this.seen = new Map(entries.slice(entries.length - this.props.maxSize));
49
+ let removeCount = this.seen.size - this.props.maxSize;
50
+ for (const id of this.seen.keys()) {
51
+ if (removeCount <= 0) break;
52
+ this.seen.delete(id);
53
+ removeCount--;
54
+ }
50
55
  }
51
56
  get size() {
52
57
  return this.seen.size;
@@ -472,108 +477,58 @@ var FlumeSlackSocketMode = class {
472
477
  //#region lib/slack/slack-source.ts
473
478
  const SEEN_CACHE_MAX = 1024;
474
479
  const SEEN_CACHE_TTL_MS = 300 * 1e3;
475
- var FlumeSlackSource = class {
480
+ var FlumeSlackSource = class extends FlumeSource {
476
481
  options;
477
482
  name = "slack";
478
483
  socket = null;
479
484
  reconnector = null;
480
- handler = null;
481
485
  internalController = null;
482
- log;
483
- deps;
484
- queue = new FlumeSerialQueue();
485
- seen;
486
- signals;
487
- statusEmitter;
488
- onSignalAbort = () => {
489
- safeInvokeCallback({
490
- fn: () => this.stop(),
491
- onError: (error) => {
492
- this.log.error({
493
- action: "signal.abort.stop.failed",
494
- message: safeErrorMessage({ error }),
495
- error
496
- });
497
- }
498
- });
499
- };
486
+ seen = null;
500
487
  constructor(options) {
488
+ super();
501
489
  this.options = options;
502
- this.deps = options.deps ?? createFlumeDefaultDeps();
503
- this.log = new FlumeLogger({
504
- source: "slack",
505
- handler: options.onLog,
506
- deps: this.deps
507
- });
508
- this.signals = new FlumeSignalRegistry({
509
- log: this.log,
510
- onAbort: this.onSignalAbort
511
- });
512
- this.statusEmitter = new FlumeStatusEmitter({
513
- log: this.log,
514
- onStatus: options.onStatus
515
- });
490
+ }
491
+ async connect(ctx) {
492
+ if (!this.hasWebSocket(ctx)) return new FlumeStartError("Slack source: deps.WebSocket is null (no WebSocket runtime available)");
516
493
  this.seen = new FlumeSlackSeenCache({
517
494
  maxSize: SEEN_CACHE_MAX,
518
495
  ttlMs: SEEN_CACHE_TTL_MS,
519
- deps: this.deps
496
+ deps: ctx.deps
520
497
  });
521
- const rc = resolveFlumeReconnectConfig(options.reconnect);
522
- if (rc) this.reconnector = new FlumeReconnector({
523
- ...rc,
524
- log: this.log,
525
- deps: this.deps
498
+ if (ctx.reconnect && !this.reconnector) this.reconnector = new FlumeReconnector({
499
+ ...ctx.reconnect,
500
+ log: ctx.log,
501
+ deps: ctx.deps
526
502
  });
527
- }
528
- async start(handler, options) {
529
- if (this.signals.isAnyAborted(this.options.signal) || this.signals.isAnyAborted(options?.signal)) return new FlumeStartError("Slack source: signal already aborted");
530
- if (!this.hasWebSocket()) return new FlumeStartError("Slack source: deps.WebSocket is null (no WebSocket runtime available)");
531
- this.signals.register(this.options.signal);
532
- this.signals.register(options?.signal);
533
- this.handler = handler;
534
503
  const controllerResult = attempt(() => new AbortController());
535
504
  if (controllerResult instanceof Error) {
536
505
  const error = safeNormalizeError({ value: controllerResult });
537
- this.log.error({
506
+ ctx.log.error({
538
507
  action: "slack.abort-controller.new.error",
539
508
  message: safeErrorMessage({ error }),
540
509
  error
541
510
  });
542
511
  this.internalController = null;
543
512
  } else this.internalController = controllerResult;
544
- this.log.info({
545
- action: "source.start",
546
- message: "starting Slack source"
547
- });
548
- return await this.connectInternal();
513
+ return await this.connectInternal(ctx);
549
514
  }
550
- async stop() {
551
- this.signals.unregisterAll();
552
- this.log.info({
553
- action: "source.stop",
554
- message: "stopping Slack source"
555
- });
556
- if (this.reconnector && !this.reconnector.aborted) this.log.debug({
515
+ disconnect() {
516
+ const ctx = this.context;
517
+ if (ctx && this.reconnector && !this.reconnector.aborted) ctx.log.debug({
557
518
  action: "reconnect.cancel",
558
519
  message: "aborting reconnector"
559
520
  });
560
521
  this.reconnector?.cancel();
561
522
  this.internalController?.abort();
562
523
  this.socket?.disconnect();
563
- await this.queue.drain();
564
524
  this.socket = null;
565
- this.handler = null;
566
525
  this.internalController = null;
567
- this.statusEmitter.set("disconnected");
568
526
  }
569
- status() {
570
- return this.statusEmitter.value;
571
- }
572
- hasWebSocket() {
573
- const result = attempt(() => Boolean(this.deps.WebSocket));
527
+ hasWebSocket(ctx) {
528
+ const result = attempt(() => Boolean(ctx.deps.WebSocket));
574
529
  if (result instanceof Error) {
575
530
  const error = safeNormalizeError({ value: result });
576
- this.log.error({
531
+ ctx.log.error({
577
532
  action: "deps.web-socket.read.error",
578
533
  message: safeErrorMessage({ error }),
579
534
  error
@@ -582,47 +537,49 @@ var FlumeSlackSource = class {
582
537
  }
583
538
  return result;
584
539
  }
585
- async connectInternal() {
586
- this.statusEmitter.set("connecting");
540
+ async connectInternal(ctx) {
541
+ this.setStatus("connecting");
587
542
  this.socket = new FlumeSlackSocketMode({
588
543
  appToken: this.options.appToken,
589
- onLog: this.options.onLog,
590
- deps: this.deps,
591
- onMessage: (envelope) => this.handleMessage(envelope),
544
+ onLog: ctx.log.handler,
545
+ deps: ctx.deps,
546
+ onMessage: (envelope) => this.handleMessage(ctx, envelope),
592
547
  onConnected: () => {
593
- if (this.reconnector && this.reconnector.attempt > 0) this.log.info({
548
+ if (this.reconnector && this.reconnector.attempt > 0) ctx.log.info({
594
549
  action: "reconnect.reset",
595
550
  message: `cleared ${this.reconnector.attempt} attempts`
596
551
  });
597
552
  this.reconnector?.reset();
598
- this.statusEmitter.set("connected");
553
+ this.setStatus("connected");
599
554
  },
600
555
  onDisconnected: () => {
601
556
  if (this.socket?.isStopped) {
602
- this.statusEmitter.set("disconnected");
557
+ this.setStatus("disconnected");
603
558
  return;
604
559
  }
605
- this.scheduleReconnect();
560
+ this.scheduleReconnect(ctx);
606
561
  }
607
562
  });
608
563
  const error = await this.socket.connect({ signal: this.internalController?.signal });
609
564
  if (error instanceof Error) {
610
- this.log.error({
565
+ ctx.log.error({
611
566
  action: "connect.failed",
612
567
  message: safeErrorMessage({ error }),
613
568
  error
614
569
  });
615
570
  if (this.socket.isStopped || !this.reconnector || this.reconnector.aborted) {
616
- this.statusEmitter.set("disconnected");
571
+ this.setStatus("disconnected");
617
572
  return error;
618
573
  }
619
- this.scheduleReconnect();
574
+ this.scheduleReconnect(ctx);
620
575
  }
621
576
  return null;
622
577
  }
623
- handleMessage(envelope) {
624
- if (this.seen.has(envelope.envelope_id)) {
625
- this.log.debug({
578
+ handleMessage(ctx, envelope) {
579
+ const seen = this.seen;
580
+ if (!seen) return;
581
+ if (seen.has(envelope.envelope_id)) {
582
+ ctx.log.debug({
626
583
  action: "dedup.skip",
627
584
  message: `duplicate envelope_id=${envelope.envelope_id}`,
628
585
  detail: {
@@ -632,31 +589,21 @@ var FlumeSlackSource = class {
632
589
  });
633
590
  return;
634
591
  }
635
- this.seen.add(envelope.envelope_id);
636
- this.seen.trim();
637
- const handler = this.handler;
638
- if (!handler) return;
639
- this.queue.add(async () => {
640
- const event = {
641
- source: "slack",
642
- type: envelope.type,
643
- data: envelope.payload,
644
- meta: this.safeExtractMeta(envelope),
645
- receivedAt: safeNow({ deps: this.deps })
646
- };
647
- const r = await attempt(() => Promise.resolve(handler(event)));
648
- if (r instanceof Error) this.log.error({
649
- action: "handler.error",
650
- message: safeErrorMessage({ error: r }),
651
- error: r
652
- });
592
+ seen.add(envelope.envelope_id);
593
+ seen.trim();
594
+ this.emit({
595
+ source: "slack",
596
+ type: envelope.type,
597
+ data: envelope.payload,
598
+ meta: this.safeExtractMeta(ctx, envelope),
599
+ receivedAt: safeNow({ deps: ctx.deps })
653
600
  });
654
601
  }
655
- safeExtractMeta(envelope) {
602
+ safeExtractMeta(ctx, envelope) {
656
603
  const result = attempt(() => flumeExtractSlackMeta(envelope));
657
604
  if (result instanceof Error) {
658
605
  const error = safeNormalizeError({ value: result });
659
- this.log.warn({
606
+ ctx.log.warn({
660
607
  action: "meta.extract.error",
661
608
  message: safeErrorMessage({ error }),
662
609
  error,
@@ -666,20 +613,20 @@ var FlumeSlackSource = class {
666
613
  }
667
614
  return result;
668
615
  }
669
- scheduleReconnect() {
616
+ scheduleReconnect(ctx) {
670
617
  scheduleFlumeReconnect({
671
618
  reconnector: this.reconnector,
672
- log: this.log,
673
- setStatus: (status) => this.statusEmitter.set(status),
619
+ log: ctx.log,
620
+ setStatus: (status) => this.setStatus(status),
674
621
  retry: () => {
675
- this.connectInternal().catch((err) => {
622
+ this.connectInternal(ctx).catch((err) => {
676
623
  const error = safeNormalizeError({ value: err });
677
- this.log.error({
624
+ ctx.log.error({
678
625
  action: "reconnect.unhandled",
679
626
  message: safeErrorMessage({ error }),
680
627
  error
681
628
  });
682
- this.statusEmitter.set("disconnected");
629
+ this.setStatus("disconnected");
683
630
  });
684
631
  }
685
632
  });
package/dist/time.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { N as FlumeTimeSourceOptions, t as FlumeSource, w as FlumeSourceStartContext } from "./flume-source.js";
2
+ import { t as FlumeParseError } from "./parse-error.js";
3
+
4
+ //#region lib/time/time-source.d.ts
5
+ /**
6
+ * cron スケジュールで tick を emit する Source。外部接続を持たないため
7
+ * 起動成功と同時に `connected` になり reconnect の対象外。
8
+ * `options.message` で tick ごとの type / data / meta を上書きできる
9
+ */
10
+ declare class FlumeTimeSource extends FlumeSource {
11
+ private readonly options;
12
+ readonly name: "time";
13
+ private scheduler;
14
+ constructor(options: FlumeTimeSourceOptions);
15
+ protected connect(ctx: FlumeSourceStartContext): Promise<Error | null>;
16
+ protected disconnect(): void;
17
+ private handleTick;
18
+ private safeMessage;
19
+ private normalizeMeta;
20
+ }
21
+ //#endregion
22
+ //#region lib/time/parse-cron.d.ts
23
+ type FlumeCron = {
24
+ source: string;
25
+ minutes: ReadonlySet<number>;
26
+ hours: ReadonlySet<number>;
27
+ daysOfMonth: ReadonlySet<number>;
28
+ months: ReadonlySet<number>;
29
+ daysOfWeek: ReadonlySet<number>; /** day-of-month フィールドが `*` 以外か。dow と両方制限時は OR マッチ (標準 cron 準拠) */
30
+ domRestricted: boolean;
31
+ dowRestricted: boolean;
32
+ };
33
+ /**
34
+ * 5 フィールド cron 式をパースする。dow は 0-7 を許可し 7 を 0 (日曜) に正規化する
35
+ */
36
+ declare function parseCron(expression: string): FlumeCron | FlumeParseError;
37
+ //#endregion
38
+ //#region lib/time/cron-next.d.ts
39
+ /**
40
+ * `afterMs` より後の最初の cron マッチ時刻 (epoch ms) を壁時計 (local time) で求める。
41
+ * 到達不能なら FlumeParseError を返す
42
+ */
43
+ declare function flumeCronNext(cron: FlumeCron, afterMs: number): number | FlumeParseError;
44
+ //#endregion
45
+ export { type FlumeCron, FlumeTimeSource, flumeCronNext, parseCron };
package/dist/time.js ADDED
@@ -0,0 +1,319 @@
1
+ import { a as FlumeStartError, c as safeNormalizeError, i as safeNow, l as safeErrorMessage, n as safeInvokeCallback, o as FlumeParseError, r as FlumeLogger, s as attempt, t as FlumeSource } from "./flume-source.js";
2
+ import { t as isRecord } from "./is-record.js";
3
+ //#region lib/time/parse-cron-field.ts
4
+ /**
5
+ * 単一 cron フィールド (minute など) の spec を許可値の Set に展開する。
6
+ * 対応: `*` / `* /n` / `a` / `a-b` / `a-b/n` とそれらのカンマ区切り。名前 (JAN, MON) は非対応
7
+ */
8
+ function parseCronField(spec, min, max) {
9
+ const values = /* @__PURE__ */ new Set();
10
+ for (const part of spec.split(",")) {
11
+ const expanded = expandCronPart(part, min, max);
12
+ if (expanded instanceof FlumeParseError) return expanded;
13
+ for (const value of expanded) values.add(value);
14
+ }
15
+ if (values.size === 0) return new FlumeParseError(`cron field empty: "${spec}"`);
16
+ return values;
17
+ }
18
+ function expandCronPart(part, min, max) {
19
+ let range = part;
20
+ let step = 1;
21
+ const slash = part.indexOf("/");
22
+ if (slash !== -1) {
23
+ range = part.slice(0, slash);
24
+ const parsed = Number(part.slice(slash + 1));
25
+ if (!Number.isInteger(parsed) || parsed <= 0) return new FlumeParseError(`invalid cron step: "${part}"`);
26
+ step = parsed;
27
+ }
28
+ const bounds = resolveBounds(range, min, max);
29
+ if (bounds instanceof FlumeParseError) return bounds;
30
+ const numbers = [];
31
+ for (let value = bounds.lo; value <= bounds.hi; value += step) numbers.push(value);
32
+ return numbers;
33
+ }
34
+ function resolveBounds(range, min, max) {
35
+ if (range === "*") return {
36
+ lo: min,
37
+ hi: max
38
+ };
39
+ const dash = range.indexOf("-");
40
+ const lo = dash === -1 ? Number(range) : Number(range.slice(0, dash));
41
+ const hi = dash === -1 ? lo : Number(range.slice(dash + 1));
42
+ if (!Number.isInteger(lo) || !Number.isInteger(hi)) return new FlumeParseError(`invalid cron range: "${range}"`);
43
+ if (lo < min || hi > max || lo > hi) return new FlumeParseError(`cron value out of range [${min}-${max}]: "${range}"`);
44
+ return {
45
+ lo,
46
+ hi
47
+ };
48
+ }
49
+ //#endregion
50
+ //#region lib/time/parse-cron.ts
51
+ /**
52
+ * 5 フィールド cron 式をパースする。dow は 0-7 を許可し 7 を 0 (日曜) に正規化する
53
+ */
54
+ function parseCron(expression) {
55
+ const trimmed = expression.trim();
56
+ const fields = trimmed.split(/\s+/);
57
+ if (fields.length !== 5) return new FlumeParseError(`cron must have 5 fields, got ${fields.length}: "${expression}"`);
58
+ const minutes = parseCronField(fields[0] ?? "", 0, 59);
59
+ if (minutes instanceof FlumeParseError) return minutes;
60
+ const hours = parseCronField(fields[1] ?? "", 0, 23);
61
+ if (hours instanceof FlumeParseError) return hours;
62
+ const daysOfMonth = parseCronField(fields[2] ?? "", 1, 31);
63
+ if (daysOfMonth instanceof FlumeParseError) return daysOfMonth;
64
+ const months = parseCronField(fields[3] ?? "", 1, 12);
65
+ if (months instanceof FlumeParseError) return months;
66
+ const rawDaysOfWeek = parseCronField(fields[4] ?? "", 0, 7);
67
+ if (rawDaysOfWeek instanceof FlumeParseError) return rawDaysOfWeek;
68
+ const daysOfWeek = /* @__PURE__ */ new Set();
69
+ for (const value of rawDaysOfWeek) daysOfWeek.add(value === 7 ? 0 : value);
70
+ return {
71
+ source: trimmed,
72
+ minutes,
73
+ hours,
74
+ daysOfMonth,
75
+ months,
76
+ daysOfWeek,
77
+ domRestricted: fields[2] !== "*",
78
+ dowRestricted: fields[4] !== "*"
79
+ };
80
+ }
81
+ //#endregion
82
+ //#region lib/time/cron-next.ts
83
+ const MINUTE_MS = 6e4;
84
+ const MAX_ITERATIONS = 5e5;
85
+ /**
86
+ * `afterMs` より後の最初の cron マッチ時刻 (epoch ms) を壁時計 (local time) で求める。
87
+ * 到達不能なら FlumeParseError を返す
88
+ */
89
+ function flumeCronNext(cron, afterMs) {
90
+ let candidate = Math.floor(afterMs / MINUTE_MS) * MINUTE_MS + MINUTE_MS;
91
+ for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
92
+ const date = new Date(candidate);
93
+ if (!cron.months.has(date.getMonth() + 1)) {
94
+ candidate = new Date(date.getFullYear(), date.getMonth() + 1, 1, 0, 0, 0, 0).getTime();
95
+ continue;
96
+ }
97
+ if (!matchesDay(cron, date)) {
98
+ candidate = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1, 0, 0, 0, 0).getTime();
99
+ continue;
100
+ }
101
+ if (!cron.hours.has(date.getHours())) {
102
+ candidate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours() + 1, 0, 0, 0).getTime();
103
+ continue;
104
+ }
105
+ if (!cron.minutes.has(date.getMinutes())) {
106
+ candidate += MINUTE_MS;
107
+ continue;
108
+ }
109
+ return candidate;
110
+ }
111
+ return new FlumeParseError(`cron "${cron.source}" has no next time within bound`);
112
+ }
113
+ function matchesDay(cron, date) {
114
+ const domMatch = cron.daysOfMonth.has(date.getDate());
115
+ const dowMatch = cron.daysOfWeek.has(date.getDay());
116
+ if (cron.domRestricted && cron.dowRestricted) return domMatch || dowMatch;
117
+ if (cron.domRestricted) return domMatch;
118
+ if (cron.dowRestricted) return dowMatch;
119
+ return true;
120
+ }
121
+ //#endregion
122
+ //#region lib/time/time-scheduler.ts
123
+ const MAX_TIMEOUT_MS = 2e9;
124
+ const FIRE_TOLERANCE_MS = 1e3;
125
+ /**
126
+ * cron に従って `onTick` を駆動するタイマーループ。外部接続を持たないため reconnect 不要。
127
+ * IO 境界は全て `attempt` 経由で扱い、停止後はコールバックを発火しない
128
+ */
129
+ var FlumeTimeScheduler = class {
130
+ props;
131
+ log;
132
+ isStoppedFlag = false;
133
+ timer = null;
134
+ target = 0;
135
+ constructor(props) {
136
+ this.props = props;
137
+ this.log = new FlumeLogger({
138
+ source: "time.scheduler",
139
+ handler: props.onLog,
140
+ deps: props.deps
141
+ });
142
+ }
143
+ get isStopped() {
144
+ return this.isStoppedFlag;
145
+ }
146
+ start() {
147
+ const next = flumeCronNext(this.props.cron, safeNow({ deps: this.props.deps }));
148
+ if (next instanceof FlumeParseError) {
149
+ this.log.error({
150
+ action: "cron.no-next",
151
+ message: next.message,
152
+ error: next
153
+ });
154
+ return next;
155
+ }
156
+ this.target = next;
157
+ this.log.info({
158
+ action: "scheduler.start",
159
+ message: `next fire at ${new Date(next).toISOString()}`,
160
+ detail: { target: next }
161
+ });
162
+ this.arm();
163
+ return null;
164
+ }
165
+ stop() {
166
+ this.isStoppedFlag = true;
167
+ this.clearTimer();
168
+ }
169
+ arm() {
170
+ this.clearTimer();
171
+ const delay = Math.max(0, this.target - safeNow({ deps: this.props.deps }));
172
+ const capped = Math.min(delay, MAX_TIMEOUT_MS);
173
+ const result = attempt(() => this.props.deps.setTimeout(() => this.onWake(), capped));
174
+ if (result instanceof Error) {
175
+ this.log.error({
176
+ action: "scheduler.arm.error",
177
+ message: safeErrorMessage({ error: result }),
178
+ error: result
179
+ });
180
+ this.timer = null;
181
+ return;
182
+ }
183
+ this.timer = result;
184
+ }
185
+ onWake() {
186
+ this.timer = null;
187
+ if (this.isStoppedFlag) return;
188
+ if (this.target - safeNow({ deps: this.props.deps }) > FIRE_TOLERANCE_MS) {
189
+ this.arm();
190
+ return;
191
+ }
192
+ const firedAt = this.target;
193
+ safeInvokeCallback({
194
+ fn: () => this.props.onTick(firedAt),
195
+ onError: (error) => {
196
+ this.log.error({
197
+ action: "scheduler.tick.error",
198
+ message: safeErrorMessage({ error }),
199
+ error
200
+ });
201
+ }
202
+ });
203
+ const next = flumeCronNext(this.props.cron, firedAt);
204
+ if (next instanceof FlumeParseError) {
205
+ this.log.error({
206
+ action: "cron.no-next",
207
+ message: next.message,
208
+ error: next
209
+ });
210
+ return;
211
+ }
212
+ this.target = next;
213
+ this.arm();
214
+ }
215
+ clearTimer() {
216
+ if (this.timer === null) return;
217
+ const handle = this.timer;
218
+ const result = attempt(() => this.props.deps.clearTimeout(handle));
219
+ if (result instanceof Error) this.log.error({
220
+ action: "scheduler.timer.clear.error",
221
+ message: safeErrorMessage({ error: result }),
222
+ error: result
223
+ });
224
+ this.timer = null;
225
+ }
226
+ };
227
+ //#endregion
228
+ //#region lib/time/time-source.ts
229
+ /**
230
+ * cron スケジュールで tick を emit する Source。外部接続を持たないため
231
+ * 起動成功と同時に `connected` になり reconnect の対象外。
232
+ * `options.message` で tick ごとの type / data / meta を上書きできる
233
+ */
234
+ var FlumeTimeSource = class extends FlumeSource {
235
+ options;
236
+ name = "time";
237
+ scheduler = null;
238
+ constructor(options) {
239
+ super();
240
+ this.options = options;
241
+ }
242
+ async connect(ctx) {
243
+ this.setStatus("connecting");
244
+ const cron = parseCron(this.options.cron);
245
+ if (cron instanceof FlumeParseError) {
246
+ const error = new FlumeStartError(`Time source: invalid cron "${this.options.cron}": ${cron.message}`);
247
+ ctx.log.error({
248
+ action: "source.start.failed",
249
+ message: safeErrorMessage({ error }),
250
+ error
251
+ });
252
+ this.setStatus("disconnected", error.message);
253
+ return error;
254
+ }
255
+ this.scheduler = new FlumeTimeScheduler({
256
+ cron,
257
+ onLog: ctx.log.handler,
258
+ deps: ctx.deps,
259
+ onTick: (firedAt) => this.handleTick(ctx, firedAt)
260
+ });
261
+ const result = this.scheduler.start();
262
+ if (result instanceof Error) {
263
+ const error = new FlumeStartError(`Time source: ${safeErrorMessage({ error: result })}`);
264
+ this.setStatus("disconnected", error.message);
265
+ return error;
266
+ }
267
+ this.setStatus("connected");
268
+ return null;
269
+ }
270
+ disconnect() {
271
+ this.scheduler?.stop();
272
+ this.scheduler = null;
273
+ }
274
+ handleTick(ctx, firedAt) {
275
+ const tick = {
276
+ firedAt,
277
+ cron: this.options.cron
278
+ };
279
+ const custom = this.safeMessage(ctx, tick);
280
+ this.emit({
281
+ source: "time",
282
+ type: typeof custom.type === "string" ? custom.type : "tick",
283
+ data: isRecord(custom.data) ? custom.data : {
284
+ firedAt,
285
+ cron: this.options.cron
286
+ },
287
+ meta: this.normalizeMeta(custom.meta, this.options.cron),
288
+ receivedAt: safeNow({ deps: ctx.deps })
289
+ });
290
+ }
291
+ safeMessage(ctx, tick) {
292
+ const message = this.options.message;
293
+ if (!message) return {};
294
+ const result = attempt(() => message(tick));
295
+ if (result instanceof Error) {
296
+ const error = safeNormalizeError({ value: result });
297
+ ctx.log.warn({
298
+ action: "message.error",
299
+ message: safeErrorMessage({ error }),
300
+ error,
301
+ detail: { firedAt: tick.firedAt }
302
+ });
303
+ return {};
304
+ }
305
+ return isRecord(result) ? result : {};
306
+ }
307
+ normalizeMeta(meta, cron) {
308
+ if (!isRecord(meta)) return { cron };
309
+ const normalized = {};
310
+ for (const key of Object.keys(meta)) {
311
+ const value = meta[key];
312
+ if (typeof value === "string") normalized[key] = value;
313
+ }
314
+ if (Object.keys(normalized).length === 0) return { cron };
315
+ return normalized;
316
+ }
317
+ };
318
+ //#endregion
319
+ export { FlumeTimeSource, flumeCronNext, parseCron };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@interactive-inc/flume",
3
- "version": "0.4.0",
3
+ "version": "0.9.0",
4
4
  "description": "Unified notification listener for Discord, Slack, and GitHub. Raw WebSocket + fetch + Zod. No SDK dependencies.",
5
5
  "keywords": [
6
6
  "discord",
@@ -54,6 +54,11 @@
54
54
  "import": "./dist/github.js",
55
55
  "default": "./dist/github.js"
56
56
  },
57
+ "./time": {
58
+ "types": "./dist/time.d.ts",
59
+ "import": "./dist/time.js",
60
+ "default": "./dist/time.js"
61
+ },
57
62
  "./package.json": "./package.json"
58
63
  },
59
64
  "publishConfig": {
@@ -71,6 +76,7 @@
71
76
  "zod": "^4.4.3"
72
77
  },
73
78
  "devDependencies": {
79
+ "typescript": "^5.6.0",
74
80
  "vite-plus": "^0.1.21",
75
81
  "vitest": "^4.1.9"
76
82
  },