@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/README.md +156 -62
- package/dist/discord.d.ts +1 -1
- package/dist/discord.js +5 -4
- package/dist/{flume-source-DuUFPhSe.d.ts → flume-source.d.ts} +52 -11
- package/dist/{flume-source-DUvt9aJt.js → flume-source.js} +7 -4
- package/dist/github.d.ts +1 -1
- package/dist/github.js +12 -7
- package/dist/index.d.ts +146 -43
- package/dist/index.js +290 -83
- package/dist/is-record.js +6 -0
- package/dist/parse-error.d.ts +9 -0
- package/dist/{safe-json-parse-CfJjt-RY.js → safe-json-parse.js} +1 -1
- package/dist/{safe-read-text-JQd_5vbd.js → safe-read-text.js} +2 -2
- package/dist/{safe-stringify-DbWQw9qe.js → safe-stringify.js} +7 -13
- package/dist/slack.d.ts +1 -1
- package/dist/slack.js +13 -8
- package/dist/time.d.ts +45 -0
- package/dist/time.js +319 -0
- package/package.json +6 -1
- /package/dist/{connection-error-HUO3PC3G.js → connection-error.js} +0 -0
- /package/dist/{http-error-CPSKoSie.js → http-error.js} +0 -0
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": {
|
|
File without changes
|
|
File without changes
|