@interactive-inc/flume 0.6.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/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.6.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": {
File without changes