@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/README.md +232 -95
- package/dist/discord.d.ts +5 -13
- package/dist/discord.js +48 -108
- package/dist/flume-source.d.ts +269 -0
- package/dist/flume-source.js +344 -0
- package/dist/github.d.ts +4 -12
- package/dist/github.js +33 -88
- package/dist/index.d.ts +133 -42
- package/dist/index.js +340 -59
- package/dist/is-record.js +6 -0
- package/dist/parse-error.d.ts +9 -0
- package/dist/safe-json-parse.js +11 -0
- package/dist/{safe-read-text-DgrJ4Uhl.js → safe-read-text.js} +2 -2
- package/dist/{safe-stringify-BWS-uXZP.js → safe-stringify.js} +7 -28
- package/dist/slack.d.ts +5 -13
- package/dist/slack.js +65 -118
- package/dist/time.d.ts +45 -0
- package/dist/time.js +319 -0
- package/package.json +7 -1
- package/dist/safe-invoke-callback-EpWXwfwp.js +0 -170
- package/dist/serial-queue-B9LoBc64.js +0 -162
- package/dist/types-D-tO-Mh2.d.ts +0 -154
- /package/dist/{connection-error-HUO3PC3G.js → connection-error.js} +0 -0
- /package/dist/{http-error-CPSKoSie.js → http-error.js} +0 -0
package/dist/slack.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import { t as FlumeConnectionError } from "./connection-error
|
|
3
|
-
import { t as FlumeHttpError } from "./http-error
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { t as
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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:
|
|
496
|
+
deps: ctx.deps
|
|
520
497
|
});
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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.
|
|
545
|
-
action: "source.start",
|
|
546
|
-
message: "starting Slack source"
|
|
547
|
-
});
|
|
548
|
-
return await this.connectInternal();
|
|
513
|
+
return await this.connectInternal(ctx);
|
|
549
514
|
}
|
|
550
|
-
|
|
551
|
-
this.
|
|
552
|
-
this.log.
|
|
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
|
-
|
|
570
|
-
|
|
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
|
-
|
|
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.
|
|
540
|
+
async connectInternal(ctx) {
|
|
541
|
+
this.setStatus("connecting");
|
|
587
542
|
this.socket = new FlumeSlackSocketMode({
|
|
588
543
|
appToken: this.options.appToken,
|
|
589
|
-
onLog:
|
|
590
|
-
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)
|
|
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.
|
|
553
|
+
this.setStatus("connected");
|
|
599
554
|
},
|
|
600
555
|
onDisconnected: () => {
|
|
601
556
|
if (this.socket?.isStopped) {
|
|
602
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
625
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
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:
|
|
673
|
-
setStatus: (status) => this.
|
|
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
|
-
|
|
624
|
+
ctx.log.error({
|
|
678
625
|
action: "reconnect.unhandled",
|
|
679
626
|
message: safeErrorMessage({ error }),
|
|
680
627
|
error
|
|
681
628
|
});
|
|
682
|
-
this.
|
|
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.
|
|
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
|
},
|