@cronwatch/sdk 0.9.0 → 0.11.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 +4 -4
- package/dist/anthropic.d.cts +1 -1
- package/dist/anthropic.d.ts +1 -1
- package/dist/bugsnag.d.cts +1 -1
- package/dist/bugsnag.d.ts +1 -1
- package/dist/{client-D35cfnNV.d.cts → client-CRMfhylq.d.cts} +129 -37
- package/dist/{client-CQm-8-mR.d.ts → client-D2Xk2600.d.ts} +129 -37
- package/dist/d1.cjs +29 -11
- package/dist/d1.cjs.map +1 -1
- package/dist/d1.d.cts +1 -1
- package/dist/d1.d.ts +1 -1
- package/dist/d1.js +29 -11
- package/dist/d1.js.map +1 -1
- package/dist/datadog.d.cts +1 -1
- package/dist/datadog.d.ts +1 -1
- package/dist/discord.cjs +25 -4
- package/dist/discord.cjs.map +1 -1
- package/dist/discord.d.cts +20 -4
- package/dist/discord.d.ts +20 -4
- package/dist/discord.js +22 -3
- package/dist/discord.js.map +1 -1
- package/dist/{email-BxCp9RQV.d.ts → email-BWuUiCkw.d.ts} +1 -1
- package/dist/{email-D1d5niMy.d.cts → email-Dcqe3k8n.d.cts} +1 -1
- package/dist/honeybadger.d.cts +1 -1
- package/dist/honeybadger.d.ts +1 -1
- package/dist/index.cjs +380 -123
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -9
- package/dist/index.d.ts +15 -9
- package/dist/index.js +379 -124
- package/dist/index.js.map +1 -1
- package/dist/mailgun.d.cts +2 -2
- package/dist/mailgun.d.ts +2 -2
- package/dist/newrelic.d.cts +1 -1
- package/dist/newrelic.d.ts +1 -1
- package/dist/pg-cron.cjs +19 -8
- package/dist/pg-cron.cjs.map +1 -1
- package/dist/pg-cron.d.cts +15 -12
- package/dist/pg-cron.d.ts +15 -12
- package/dist/pg-cron.js +16 -5
- package/dist/pg-cron.js.map +1 -1
- package/dist/postgres.cjs +28 -10
- package/dist/postgres.cjs.map +1 -1
- package/dist/postgres.d.cts +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.js +28 -10
- package/dist/postgres.js.map +1 -1
- package/dist/postmark.d.cts +2 -2
- package/dist/postmark.d.ts +2 -2
- package/dist/resend.d.cts +2 -2
- package/dist/resend.d.ts +2 -2
- package/dist/rollbar.d.cts +1 -1
- package/dist/rollbar.d.ts +1 -1
- package/dist/sendgrid.d.cts +2 -2
- package/dist/sendgrid.d.ts +2 -2
- package/dist/sentry.cjs +4 -1
- package/dist/sentry.cjs.map +1 -1
- package/dist/sentry.d.cts +5 -2
- package/dist/sentry.d.ts +5 -2
- package/dist/sentry.js +4 -1
- package/dist/sentry.js.map +1 -1
- package/dist/ses.d.cts +2 -2
- package/dist/ses.d.ts +2 -2
- package/dist/slack.d.cts +1 -1
- package/dist/slack.d.ts +1 -1
- package/dist/sqlite.cjs +29 -11
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.d.cts +1 -1
- package/dist/sqlite.d.ts +1 -1
- package/dist/sqlite.js +29 -11
- package/dist/sqlite.js.map +1 -1
- package/dist/twilio.cjs +10 -4
- package/dist/twilio.cjs.map +1 -1
- package/dist/twilio.d.cts +12 -7
- package/dist/twilio.d.ts +12 -7
- package/dist/twilio.js +7 -1
- package/dist/twilio.js.map +1 -1
- package/dist/{types-Cz47Boks.d.cts → types-DLnlUie8.d.cts} +18 -2
- package/dist/{types-Cz47Boks.d.ts → types-DLnlUie8.d.ts} +18 -2
- package/dist/webhook.cjs +10 -5
- package/dist/webhook.cjs.map +1 -1
- package/dist/webhook.d.cts +18 -7
- package/dist/webhook.d.ts +18 -7
- package/dist/webhook.js +10 -6
- package/dist/webhook.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -78,13 +78,46 @@ function beyondDates(at) {
|
|
|
78
78
|
function readEnv(name) {
|
|
79
79
|
return typeof process === "undefined" || !process.env ? void 0 : process.env[name];
|
|
80
80
|
}
|
|
81
|
+
function readSecretEnv(name) {
|
|
82
|
+
const value = readEnv(name);
|
|
83
|
+
return value === void 0 || value.trim() === "" ? void 0 : value;
|
|
84
|
+
}
|
|
85
|
+
function secretOption(value, what) {
|
|
86
|
+
if (value === void 0 || value === null) return value;
|
|
87
|
+
if (typeof value !== "string") {
|
|
88
|
+
throw new TypeError(`${what} must be a string, or null to opt out, not ${Array.isArray(value) ? "an array" : typeof value}`);
|
|
89
|
+
}
|
|
90
|
+
return value.trim() === "" ? void 0 : value;
|
|
91
|
+
}
|
|
92
|
+
function environment() {
|
|
93
|
+
if (typeof process === "undefined" || !process.env) return "";
|
|
94
|
+
for (const value of [process.env.CRONWATCH_ENV, process.env.APP_ENV, process.env.NODE_ENV]) {
|
|
95
|
+
const name = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
96
|
+
if (name === "") continue;
|
|
97
|
+
if (name === "prod") return "production";
|
|
98
|
+
if (name === "dev" || name === "local" || name === "test" || name === "testing") return "development";
|
|
99
|
+
return name;
|
|
100
|
+
}
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
81
103
|
function isDevelopment() {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
104
|
+
return environment() === "development";
|
|
105
|
+
}
|
|
106
|
+
function isProduction() {
|
|
107
|
+
return environment() === "production";
|
|
85
108
|
}
|
|
86
109
|
var cache = /* @__PURE__ */ new Map();
|
|
87
110
|
var crons = /* @__PURE__ */ new WeakMap();
|
|
111
|
+
var never = /* @__PURE__ */ new WeakSet();
|
|
112
|
+
var PROBE_FROM_MS = Date.UTC(2e3, 0, 1);
|
|
113
|
+
function neverFires(cron) {
|
|
114
|
+
try {
|
|
115
|
+
return cron.nextRuns(1, new Date(PROBE_FROM_MS)).length === 0;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return error instanceof RangeError && /call stack/i.test(error.message);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
var ONE_TIME_DATE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
|
|
88
121
|
var EARLY_SLACK_MS = 6e4;
|
|
89
122
|
function parseSchedule(schedule, timezone) {
|
|
90
123
|
const key = `${timezone ?? ""}|${schedule}`;
|
|
@@ -98,6 +131,10 @@ function parseSchedule(schedule, timezone) {
|
|
|
98
131
|
if (everyMs < 1e3) throw new Error(`schedule "${schedule}" is shorter than one second`);
|
|
99
132
|
parsed = { kind: "interval", source: text, everyMs };
|
|
100
133
|
} else {
|
|
134
|
+
if (text.slice(1).includes(":")) {
|
|
135
|
+
const reason = ONE_TIME_DATE.test(text) ? "CronPattern: a one-time date is not supported" : "Invalid ISO8601 passed to timezone parser.";
|
|
136
|
+
throw new Error(`schedule "${schedule}" is not a cron expression or "every <duration>": ${reason}`);
|
|
137
|
+
}
|
|
101
138
|
let cron;
|
|
102
139
|
try {
|
|
103
140
|
cron = new Cron(text, timezone ? { timezone } : {});
|
|
@@ -106,6 +143,7 @@ function parseSchedule(schedule, timezone) {
|
|
|
106
143
|
}
|
|
107
144
|
parsed = { kind: "cron", source: text, ...timezone ? { timezone } : {} };
|
|
108
145
|
crons.set(parsed, cron);
|
|
146
|
+
if (neverFires(cron)) never.add(parsed);
|
|
109
147
|
}
|
|
110
148
|
cache.set(key, parsed);
|
|
111
149
|
return parsed;
|
|
@@ -132,6 +170,7 @@ function countFrom(from) {
|
|
|
132
170
|
function fireAfter(parsed, from) {
|
|
133
171
|
const cron = crons.get(parsed);
|
|
134
172
|
if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
|
|
173
|
+
if (never.has(parsed)) return null;
|
|
135
174
|
const start = countFrom(from);
|
|
136
175
|
if (start === null) return null;
|
|
137
176
|
let probe = start;
|
|
@@ -147,6 +186,7 @@ function fireAfter(parsed, from) {
|
|
|
147
186
|
function firesBetween(parsed, from, to, limit) {
|
|
148
187
|
const cron = crons.get(parsed);
|
|
149
188
|
if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
|
|
189
|
+
if (never.has(parsed)) return [];
|
|
150
190
|
const out = [];
|
|
151
191
|
const start = countFrom(from);
|
|
152
192
|
if (start === null) return out;
|
|
@@ -266,6 +306,9 @@ function runDuration(startedAt, finishedAt) {
|
|
|
266
306
|
const ms = finishedAt - startedAt;
|
|
267
307
|
return ms > 0 ? Math.min(ms, MAX_DURATION_MS) : 0;
|
|
268
308
|
}
|
|
309
|
+
function silenceEnd(now, ms) {
|
|
310
|
+
return Math.min(now + Math.floor(Math.min(ms, MAX_DURATION_MS)), MAX_DURATION_MS);
|
|
311
|
+
}
|
|
269
312
|
function stateVersion(state) {
|
|
270
313
|
const version = state?.version;
|
|
271
314
|
return typeof version === "number" && Number.isSafeInteger(version) && version >= 0 ? version + 0 : 0;
|
|
@@ -278,17 +321,74 @@ function failureCount(state) {
|
|
|
278
321
|
function emptyState(job) {
|
|
279
322
|
return { job, open: {}, consecutiveFailures: 0, silencedUntil: null, lastAlertAt: null, pendingRecovery: [], undelivered: [] };
|
|
280
323
|
}
|
|
324
|
+
function isObject(value) {
|
|
325
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
326
|
+
}
|
|
281
327
|
function normalizeState(state, job) {
|
|
282
|
-
if (!state) return emptyState(job);
|
|
328
|
+
if (!isObject(state)) return emptyState(job);
|
|
329
|
+
const { sending, ...rest } = state;
|
|
330
|
+
const open = isObject(state.open) ? Object.fromEntries(Object.entries(state.open).filter(([, at]) => typeof at === "number")) : {};
|
|
331
|
+
const number = (value) => typeof value === "number" ? value : null;
|
|
332
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
283
333
|
return {
|
|
284
334
|
...emptyState(job),
|
|
285
|
-
...
|
|
286
|
-
open
|
|
335
|
+
...rest,
|
|
336
|
+
open,
|
|
287
337
|
consecutiveFailures: failureCount(state),
|
|
288
|
-
|
|
289
|
-
|
|
338
|
+
silencedUntil: number(state.silencedUntil),
|
|
339
|
+
lastAlertAt: number(state.lastAlertAt),
|
|
340
|
+
pendingRecovery: list(state.pendingRecovery).filter((c) => typeof c === "string"),
|
|
341
|
+
undelivered: list(state.undelivered).filter((alert) => isObject(alert)),
|
|
342
|
+
...Array.isArray(sending) && sending.length > 0 ? { sending: [...sending] } : {}
|
|
290
343
|
};
|
|
291
344
|
}
|
|
345
|
+
var MAX_UNDELIVERED = 20;
|
|
346
|
+
var SEND_LEASE_MS = 5 * 6e4;
|
|
347
|
+
function alertKey(alert) {
|
|
348
|
+
return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
|
|
349
|
+
}
|
|
350
|
+
function newest(list, max) {
|
|
351
|
+
return { kept: list.slice(-max), dropped: Math.max(0, list.length - max) };
|
|
352
|
+
}
|
|
353
|
+
function queueUndelivered(state, alerts) {
|
|
354
|
+
const next = cloneState(state);
|
|
355
|
+
const byKey = new Map(alerts.map((alert) => [alertKey(alert), alert]));
|
|
356
|
+
const queue = next.undelivered.map((alert) => byKey.get(alertKey(alert)) ?? alert);
|
|
357
|
+
const known = new Set(queue.map(alertKey));
|
|
358
|
+
queue.push(...alerts.filter((alert) => !known.has(alertKey(alert))));
|
|
359
|
+
const { kept, dropped } = newest(queue, MAX_UNDELIVERED);
|
|
360
|
+
next.undelivered = kept;
|
|
361
|
+
return { state: next, dropped };
|
|
362
|
+
}
|
|
363
|
+
function holdAlerts(state, alerts, until, deferred) {
|
|
364
|
+
if (alerts.length === 0) return { state, dropped: 0 };
|
|
365
|
+
if (deferred) return queueUndelivered(state, alerts);
|
|
366
|
+
const next = cloneState(state);
|
|
367
|
+
const { kept, dropped } = newest([...next.sending ?? [], ...alerts.map((alert) => ({ until, alert }))], MAX_UNDELIVERED);
|
|
368
|
+
next.sending = kept;
|
|
369
|
+
return { state: next, dropped };
|
|
370
|
+
}
|
|
371
|
+
function releaseSending(state, now) {
|
|
372
|
+
const sending = state.sending ?? [];
|
|
373
|
+
const lapsed = sending.filter((entry) => !(typeof entry?.until === "number" && entry.until > now));
|
|
374
|
+
if (lapsed.length === 0) return { state, dropped: 0 };
|
|
375
|
+
const held = sending.filter((entry) => !lapsed.includes(entry));
|
|
376
|
+
return queueUndelivered(
|
|
377
|
+
{ ...state, sending: held },
|
|
378
|
+
lapsed.filter((entry) => typeof entry?.alert === "object" && entry.alert !== null).map((entry) => entry.alert)
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
function recordSent(state, delivered, failed, stale, now) {
|
|
382
|
+
const next = cloneState(state);
|
|
383
|
+
const done = new Set([...delivered, ...stale].map(alertKey));
|
|
384
|
+
next.undelivered = next.undelivered.filter((alert) => !done.has(alertKey(alert)));
|
|
385
|
+
const sent = new Set([...delivered, ...failed, ...stale].map(alertKey));
|
|
386
|
+
const held = (next.sending ?? []).filter((entry) => !(typeof entry?.alert === "object" && entry.alert !== null && sent.has(alertKey(entry.alert))));
|
|
387
|
+
if (held.length > 0) next.sending = held;
|
|
388
|
+
else delete next.sending;
|
|
389
|
+
if (delivered.length > 0) next.lastAlertAt = now;
|
|
390
|
+
return queueUndelivered(next, failed);
|
|
391
|
+
}
|
|
292
392
|
function cloneState(state) {
|
|
293
393
|
return normalizeState(state, state.job);
|
|
294
394
|
}
|
|
@@ -442,8 +542,12 @@ function applySilence(previous, evaluation, now) {
|
|
|
442
542
|
return { state: muteOpens(previous, evaluation.state), alerts: [] };
|
|
443
543
|
}
|
|
444
544
|
function staleAlert(alert, state) {
|
|
445
|
-
if (alert.type === "recovered")
|
|
446
|
-
|
|
545
|
+
if (alert.type === "recovered") {
|
|
546
|
+
const after2 = isObject(alert.details) ? alert.details.after : void 0;
|
|
547
|
+
if (!Array.isArray(after2)) return true;
|
|
548
|
+
return after2.some((condition) => typeof condition !== "string" || state.open[condition] !== void 0);
|
|
549
|
+
}
|
|
550
|
+
return typeof alert.at !== "number" || state.open[alert.type] !== alert.at;
|
|
447
551
|
}
|
|
448
552
|
function jobHealth(def, lastRun, state, now) {
|
|
449
553
|
const open = openConditions(state);
|
|
@@ -592,7 +696,16 @@ var OUTPUT_CAP = 16 * 1024;
|
|
|
592
696
|
function capOutput(text) {
|
|
593
697
|
const clean = stripNul(text);
|
|
594
698
|
if (clean.length <= OUTPUT_CAP) return clean;
|
|
595
|
-
return
|
|
699
|
+
return TRIMMED + clean.slice(clean.length - OUTPUT_CAP);
|
|
700
|
+
}
|
|
701
|
+
var TRIMMED = "[earlier output trimmed]\n";
|
|
702
|
+
var REDACT_EDGE = 3 * (OUTPUT_CAP + 1024);
|
|
703
|
+
function redactAndCap(text, redact) {
|
|
704
|
+
const clean = stripNul(text);
|
|
705
|
+
const from = clean.length - (OUTPUT_CAP + REDACT_EDGE);
|
|
706
|
+
if (from <= 0) return capOutput(redact(clean));
|
|
707
|
+
const redacted = stripNul(redact(clean.slice(from)));
|
|
708
|
+
return TRIMMED + redacted.slice(Math.max(redacted.length - OUTPUT_CAP, REDACT_EDGE));
|
|
596
709
|
}
|
|
597
710
|
function stripNul(text) {
|
|
598
711
|
return text.includes("\0") ? text.replace(/\u0000/g, "") : text;
|
|
@@ -600,9 +713,6 @@ function stripNul(text) {
|
|
|
600
713
|
function stripJsonNul(json2) {
|
|
601
714
|
return json2.includes("\\u0000") ? json2.replace(/\\(u0000|[\s\S])/g, (escape, next) => next === "u0000" ? "" : escape) : json2;
|
|
602
715
|
}
|
|
603
|
-
function errorMessage(error) {
|
|
604
|
-
return capOutput(describeError(error));
|
|
605
|
-
}
|
|
606
716
|
function describeError(error) {
|
|
607
717
|
if (error instanceof Error) {
|
|
608
718
|
const frames = (error.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line)).slice(0, 5);
|
|
@@ -711,7 +821,7 @@ function createRecorder(run) {
|
|
|
711
821
|
};
|
|
712
822
|
return {
|
|
713
823
|
context,
|
|
714
|
-
output: () => lines.length === 0 ? null :
|
|
824
|
+
output: () => lines.length === 0 ? null : lines.join("\n"),
|
|
715
825
|
expectText: () => {
|
|
716
826
|
if (lines.length === 0) return null;
|
|
717
827
|
const all = lines.join("\n");
|
|
@@ -750,6 +860,15 @@ function checkExpectation(expect, output) {
|
|
|
750
860
|
}
|
|
751
861
|
return ok ? null : "Output did not pass the expect() check";
|
|
752
862
|
}
|
|
863
|
+
function readStoredJob(stored) {
|
|
864
|
+
const definition = stored.definition;
|
|
865
|
+
if (typeof definition !== "object" || definition === null || Array.isArray(definition)) {
|
|
866
|
+
return { job: { ...stored, definition: { name: stored.name } }, readable: false };
|
|
867
|
+
}
|
|
868
|
+
const read = { ...definition };
|
|
869
|
+
if ("tags" in read && !(Array.isArray(read.tags) && read.tags.every((tag) => typeof tag === "string"))) delete read.tags;
|
|
870
|
+
return { job: { ...stored, definition: read }, readable: true };
|
|
871
|
+
}
|
|
753
872
|
|
|
754
873
|
// src/routes/escape.ts
|
|
755
874
|
function escapeHtml(value) {
|
|
@@ -1452,7 +1571,7 @@ function jobPage(job, runs, now, base, complete = true) {
|
|
|
1452
1571
|
run.error ? `<details class="out error" open><summary>error</summary><pre>${h(run.error)}</pre></details>` : "",
|
|
1453
1572
|
run.output ? `<details class="out"${run.status === "ok" ? "" : " open"}><summary>output</summary><pre>${h(run.output)}</pre></details>` : ""
|
|
1454
1573
|
].join("");
|
|
1455
|
-
const metrics = Object.entries(run.metrics).map(([k, v]) => `<span><span class="k">${h(k)}</span> ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join("");
|
|
1574
|
+
const metrics = Object.entries(run.metrics ?? {}).filter(([, v]) => typeof v === "number" && Number.isFinite(v)).map(([k, v]) => `<span><span class="k">${h(k)}</span> ${h(Number.isInteger(v) ? v : v.toFixed(4))}</span>`).join("");
|
|
1456
1575
|
return `<tr${detail ? ` class="has-detail"` : ""}>
|
|
1457
1576
|
<td class="nowrap">${runState(run)}</td>
|
|
1458
1577
|
<td class="nowrap">${h(when2(run.startedAt, now))} <span class="muted">UTC</span><span class="sub">${stamp(run.startedAt, now)}</span></td>
|
|
@@ -1521,12 +1640,18 @@ function jobPage(job, runs, now, base, complete = true) {
|
|
|
1521
1640
|
return layout(`${job.name}: CronWatch`, body, base, { refresh: 60 });
|
|
1522
1641
|
}
|
|
1523
1642
|
function messagePage(title, message, base, signIn = false) {
|
|
1524
|
-
const form = signIn ? `<form class="signin" method="
|
|
1643
|
+
const form = signIn ? `<form class="signin" method="post" action="${h(base)}/signin"><label for="token">Token</label><input id="token" name="token" type="password" autocomplete="current-password" autocapitalize="off" spellcheck="false" required><button class="primary" type="submit">Sign in</button></form>` : "";
|
|
1525
1644
|
return layout(title, `<header class="top">${brand(base)}</header><main class="message"><h1>${h(title)}</h1><p>${h(message)}</p>${form}</main>`, base);
|
|
1526
1645
|
}
|
|
1527
1646
|
|
|
1647
|
+
// src/version.ts
|
|
1648
|
+
var VERSION = "0.11.0";
|
|
1649
|
+
var API_VERSION = 1;
|
|
1650
|
+
|
|
1528
1651
|
// src/routes/index.ts
|
|
1529
1652
|
var COOKIE = "cronwatch_token";
|
|
1653
|
+
var LIBRARY = "@cronwatch/sdk";
|
|
1654
|
+
var LANGUAGE = "typescript";
|
|
1530
1655
|
async function cookieValue(token) {
|
|
1531
1656
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`cronwatch-cookie:${token}`));
|
|
1532
1657
|
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -1610,6 +1735,20 @@ function forwardedOrigin(request, url) {
|
|
|
1610
1735
|
return url.origin;
|
|
1611
1736
|
}
|
|
1612
1737
|
}
|
|
1738
|
+
function bearerToken(header) {
|
|
1739
|
+
const match = header === null ? null : /^Bearer\s+([\s\S]*)$/i.exec(header);
|
|
1740
|
+
return match ? match[1] : null;
|
|
1741
|
+
}
|
|
1742
|
+
function signInReturn(referer, publicOrigin, base) {
|
|
1743
|
+
if (referer === null || !referer.startsWith(publicOrigin + "/")) return `${base}/`;
|
|
1744
|
+
let url;
|
|
1745
|
+
try {
|
|
1746
|
+
url = new URL(referer);
|
|
1747
|
+
} catch {
|
|
1748
|
+
return `${base}/`;
|
|
1749
|
+
}
|
|
1750
|
+
return url.searchParams.has("token") ? `${base}/` : referer;
|
|
1751
|
+
}
|
|
1613
1752
|
function stripBase(pathname, base) {
|
|
1614
1753
|
let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
|
|
1615
1754
|
if (path === "") path = "/";
|
|
@@ -1652,8 +1791,12 @@ function runsLimit(value) {
|
|
|
1652
1791
|
return Number.isFinite(n) ? Math.min(MAX_RUNS, Math.max(1, n)) : DEFAULT_RUNS;
|
|
1653
1792
|
}
|
|
1654
1793
|
function createRoutes(cw, options = {}) {
|
|
1794
|
+
return cw.routes(options);
|
|
1795
|
+
}
|
|
1796
|
+
function buildRoutes(cw, options = {}) {
|
|
1655
1797
|
const optedOut = options.token === null;
|
|
1656
|
-
const
|
|
1798
|
+
const given = secretOption(options.token, "routes: token");
|
|
1799
|
+
const configured = optedOut ? null : given || readSecretEnv("CRONWATCH_TOKEN") || null;
|
|
1657
1800
|
const base = (options.basePath ?? "/cronwatch").replace(/\/+$/, "");
|
|
1658
1801
|
const developing = isDevelopment();
|
|
1659
1802
|
const fixedOrigin = configuredOrigin(options.origin);
|
|
@@ -1685,7 +1828,17 @@ function createRoutes(cw, options = {}) {
|
|
|
1685
1828
|
if (method !== "GET" && method !== "HEAD" && crossSite(request, publicOrigin)) {
|
|
1686
1829
|
return wantsHtml ? html(messagePage("Cross-site request refused", "Changes can only be made from the dashboard itself.", base), 403) : api({ ok: false, error: "Cross-site request refused" }, 403);
|
|
1687
1830
|
}
|
|
1688
|
-
const bearer = request.headers.get("authorization")
|
|
1831
|
+
const bearer = bearerToken(request.headers.get("authorization"));
|
|
1832
|
+
const signInCookie = async () => {
|
|
1833
|
+
const secure = publicOrigin.startsWith("https:") ? "; Secure" : "";
|
|
1834
|
+
return `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`;
|
|
1835
|
+
};
|
|
1836
|
+
const signInPage = () => generated ? html(messagePage("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once, or enter the token from it below, and this browser stays signed in.", base, true), 401) : html(messagePage("Sign in", "Enter your CRONWATCH_TOKEN and this browser stays signed in.", base, true), 401);
|
|
1837
|
+
if (token && method === "POST" && path === "/signin") {
|
|
1838
|
+
const sent = (await readBody(request)).token;
|
|
1839
|
+
if (sent === void 0 || !constantTimeEqual(sent, token)) return signInPage();
|
|
1840
|
+
return redirect(signInReturn(request.headers.get("referer"), publicOrigin, base), { "set-cookie": await signInCookie() });
|
|
1841
|
+
}
|
|
1689
1842
|
if (token) {
|
|
1690
1843
|
const query = wantsHtml && method === "GET" ? url.searchParams.get("token") : null;
|
|
1691
1844
|
const sent = readCookie(request, COOKIE);
|
|
@@ -1693,17 +1846,12 @@ function createRoutes(cw, options = {}) {
|
|
|
1693
1846
|
const cronSecretOk = isCheck && bearer !== null && cw.cronSecret !== null && constantTimeEqual(bearer, cw.cronSecret);
|
|
1694
1847
|
const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : sent !== null && constantTimeEqual(sent, await expectedCookie(token));
|
|
1695
1848
|
if (!cronSecretOk && !tokenOk) {
|
|
1696
|
-
if (
|
|
1697
|
-
|
|
1698
|
-
}
|
|
1699
|
-
return wantsHtml ? html(messagePage("Sign in", `Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.`, base, true), 401) : api({ ok: false, error: "Unauthorized" }, 401);
|
|
1849
|
+
if (wantsHtml) return signInPage();
|
|
1850
|
+
return generated ? api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401) : api({ ok: false, error: "Unauthorized" }, 401);
|
|
1700
1851
|
}
|
|
1701
1852
|
if (query !== null) {
|
|
1702
1853
|
url.searchParams.delete("token");
|
|
1703
|
-
|
|
1704
|
-
return redirect(url.pathname + (url.search || ""), {
|
|
1705
|
-
"set-cookie": `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
|
|
1706
|
-
});
|
|
1854
|
+
return redirect(url.pathname + (url.search || ""), { "set-cookie": await signInCookie() });
|
|
1707
1855
|
}
|
|
1708
1856
|
}
|
|
1709
1857
|
const redirectBack = () => {
|
|
@@ -1754,6 +1902,9 @@ function createRoutes(cw, options = {}) {
|
|
|
1754
1902
|
}
|
|
1755
1903
|
if (parts[0] === "api") {
|
|
1756
1904
|
const rest = parts.slice(1);
|
|
1905
|
+
if (method === "GET" && rest.length === 0) {
|
|
1906
|
+
return api({ ok: true, library: LIBRARY, language: LANGUAGE, version: VERSION, api: API_VERSION });
|
|
1907
|
+
}
|
|
1757
1908
|
if (method === "GET" && rest[0] === "jobs" && rest.length === 1) {
|
|
1758
1909
|
return api({ ok: true, jobs: await cw.jobs() });
|
|
1759
1910
|
}
|
|
@@ -1781,9 +1932,13 @@ function createRoutes(cw, options = {}) {
|
|
|
1781
1932
|
} catch (e) {
|
|
1782
1933
|
return api({ ok: false, error: e.message }, 400);
|
|
1783
1934
|
}
|
|
1784
|
-
|
|
1935
|
+
await cw.silence(name, duration);
|
|
1936
|
+
return api({ ok: true, job: await cw.jobSummary(name) });
|
|
1937
|
+
}
|
|
1938
|
+
if (rest[2] === "unsilence") {
|
|
1939
|
+
await cw.unsilence(name);
|
|
1940
|
+
return api({ ok: true, job: await cw.jobSummary(name) });
|
|
1785
1941
|
}
|
|
1786
|
-
if (rest[2] === "unsilence") return api({ ok: true, state: await cw.unsilence(name) });
|
|
1787
1942
|
}
|
|
1788
1943
|
if (rest[0] === "check" && rest.length === 1) {
|
|
1789
1944
|
if (method === "GET" && bearer === null) {
|
|
@@ -1931,11 +2086,11 @@ function memory() {
|
|
|
1931
2086
|
return true;
|
|
1932
2087
|
},
|
|
1933
2088
|
async prune(before) {
|
|
1934
|
-
const
|
|
1935
|
-
for (const run of runs.values())
|
|
2089
|
+
const newest2 = /* @__PURE__ */ new Map();
|
|
2090
|
+
for (const run of runs.values()) newest2.set(run.job, Math.max(newest2.get(run.job) ?? -Infinity, run.startedAt));
|
|
1936
2091
|
let n = 0;
|
|
1937
2092
|
for (const [id, run] of runs) {
|
|
1938
|
-
if (run.status !== "running" && run.startedAt < before && run.startedAt <
|
|
2093
|
+
if (run.status !== "running" && run.startedAt < before && run.startedAt < newest2.get(run.job)) {
|
|
1939
2094
|
runs.delete(id);
|
|
1940
2095
|
order.delete(id);
|
|
1941
2096
|
n++;
|
|
@@ -1951,7 +2106,6 @@ var NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
|
|
|
1951
2106
|
var TRIAGE_TIMEOUT_MS = 25e3;
|
|
1952
2107
|
var CHANNEL_TIMEOUT_MS = 15e3;
|
|
1953
2108
|
var PRUNE_INTERVAL_MS = 60 * 6e4;
|
|
1954
|
-
var MAX_UNDELIVERED = 20;
|
|
1955
2109
|
var RETRY_BUDGET_MS = 2e4;
|
|
1956
2110
|
var STATE_ATTEMPTS = 10;
|
|
1957
2111
|
var HISTORY_PAGE = BASELINE_WINDOW + 5;
|
|
@@ -1992,10 +2146,7 @@ function validateDefinition(def) {
|
|
|
1992
2146
|
function sameState(a, b) {
|
|
1993
2147
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
1994
2148
|
}
|
|
1995
|
-
|
|
1996
|
-
return `${alert.type}|${alert.at}|${alert.run?.id ?? ""}`;
|
|
1997
|
-
}
|
|
1998
|
-
var CronWatch = class {
|
|
2149
|
+
var Cronwatch = class {
|
|
1999
2150
|
store;
|
|
2000
2151
|
alerts;
|
|
2001
2152
|
triage;
|
|
@@ -2008,7 +2159,7 @@ var CronWatch = class {
|
|
|
2008
2159
|
/** cronSecret was passed as null: handlers may run without a secret. */
|
|
2009
2160
|
secretOptOut;
|
|
2010
2161
|
redact;
|
|
2011
|
-
/** "check": queue alerts for another process's check instead of sending them. See
|
|
2162
|
+
/** "check": queue alerts for another process's check instead of sending them. See CronwatchOptions.deliver. */
|
|
2012
2163
|
deferDelivery;
|
|
2013
2164
|
defaults;
|
|
2014
2165
|
definitions = /* @__PURE__ */ new Map();
|
|
@@ -2027,12 +2178,15 @@ var CronWatch = class {
|
|
|
2027
2178
|
usingDefaultStore = false;
|
|
2028
2179
|
warnedNoSecret = false;
|
|
2029
2180
|
warnedDeferredStart = false;
|
|
2181
|
+
/** Stored jobs read with a definition that was not a JSON object (see readJob). */
|
|
2182
|
+
unreadable = /* @__PURE__ */ new WeakSet();
|
|
2030
2183
|
constructor(options = {}) {
|
|
2031
2184
|
this.store = options.store ?? (this.usingDefaultStore = true, memory());
|
|
2032
2185
|
this.alerts = options.alerts ?? [consoleChannel()];
|
|
2033
2186
|
this.triage = options.triage;
|
|
2034
2187
|
this.sources = options.sources ?? [];
|
|
2035
|
-
const
|
|
2188
|
+
const given = secretOption(options.cronSecret, "cronSecret");
|
|
2189
|
+
const secret = options.cronSecret === void 0 ? readSecretEnv("CRON_SECRET") : given;
|
|
2036
2190
|
this.cronSecret = secret ? secret : null;
|
|
2037
2191
|
this.secretOptOut = options.cronSecret === null;
|
|
2038
2192
|
this.retentionMs = parseDuration(options.retention ?? "30d", "retention");
|
|
@@ -2093,7 +2247,7 @@ var CronWatch = class {
|
|
|
2093
2247
|
return outcome.result;
|
|
2094
2248
|
},
|
|
2095
2249
|
handler(fn, options) {
|
|
2096
|
-
const own = options?.secret;
|
|
2250
|
+
const own = secretOption(options?.secret, "handler: secret");
|
|
2097
2251
|
const secret = own === null ? null : own ? own : self.cronSecret;
|
|
2098
2252
|
const optedOut = own === null || !own && self.secretOptOut;
|
|
2099
2253
|
return async (request) => {
|
|
@@ -2145,7 +2299,7 @@ var CronWatch = class {
|
|
|
2145
2299
|
if (!this.ready) {
|
|
2146
2300
|
this.ready = (async () => {
|
|
2147
2301
|
if (this.store.init) await this.store.init();
|
|
2148
|
-
if (this.usingDefaultStore &&
|
|
2302
|
+
if (this.usingDefaultStore && isProduction()) {
|
|
2149
2303
|
console.warn("[cronwatch] using the in-memory store: runs and state are lost on restart. Pass a store from @cronwatch/sdk/sqlite or @cronwatch/sdk/postgres.");
|
|
2150
2304
|
}
|
|
2151
2305
|
})().catch((error) => {
|
|
@@ -2161,19 +2315,58 @@ var CronWatch = class {
|
|
|
2161
2315
|
* that replaced it, never its own over it, and one forgotten since writes
|
|
2162
2316
|
* its own. The writes of one name take turns, in the order they were asked
|
|
2163
2317
|
* for, so one still under way cannot land after a later one; and a name
|
|
2164
|
-
* declared again while its write was under way is still to be written.
|
|
2318
|
+
* declared again while its write was under way is still to be written. A
|
|
2319
|
+
* name is marked as written only while that same declaration stands, so a
|
|
2320
|
+
* forget that lands during the write (deleting the row after it) leaves
|
|
2321
|
+
* the name to be written again, as does one forgotten before it.
|
|
2322
|
+
*
|
|
2323
|
+
* With `confirm`, as a run starts, a name already written is read back:
|
|
2324
|
+
* another process may have forgotten the job since, and a job still
|
|
2325
|
+
* declared here comes back on its next run.
|
|
2165
2326
|
*/
|
|
2166
|
-
async sync(definition) {
|
|
2327
|
+
async sync(definition, confirm = false) {
|
|
2167
2328
|
await this.ensureReady();
|
|
2168
2329
|
const name = definition.name;
|
|
2169
|
-
if (this.synced.has(name))
|
|
2330
|
+
if (this.synced.has(name)) {
|
|
2331
|
+
if (!confirm || await this.store.getJob(name)) return;
|
|
2332
|
+
this.synced.delete(name);
|
|
2333
|
+
}
|
|
2170
2334
|
await this.serial(name, async () => {
|
|
2171
2335
|
if (this.synced.has(name)) return;
|
|
2172
2336
|
const standing = this.definitions.get(name) ?? definition;
|
|
2173
2337
|
await this.store.upsertJob(toStored(standing), this.now());
|
|
2174
|
-
if (
|
|
2338
|
+
if (this.definitions.get(name) === standing) this.synced.add(name);
|
|
2175
2339
|
}, this.syncing);
|
|
2176
2340
|
}
|
|
2341
|
+
/**
|
|
2342
|
+
* Every stored job, once each declaration has been written. A job declared
|
|
2343
|
+
* here that the store no longer has was forgotten by another process after
|
|
2344
|
+
* this one wrote it: it is written again, as its next run would, so it is
|
|
2345
|
+
* checked and shown while any process still declares it.
|
|
2346
|
+
*/
|
|
2347
|
+
async storedJobs() {
|
|
2348
|
+
for (const definition of this.definitions.values()) await this.sync(definition);
|
|
2349
|
+
const jobs = await this.store.listJobs();
|
|
2350
|
+
const listed = new Set(jobs.map((job) => job.name));
|
|
2351
|
+
const missing = [...this.definitions.values()].filter((definition) => !listed.has(definition.name));
|
|
2352
|
+
if (missing.length === 0) return jobs.map((job) => this.readJob(job));
|
|
2353
|
+
for (const definition of missing) {
|
|
2354
|
+
if (this.definitions.get(definition.name) !== definition) continue;
|
|
2355
|
+
this.synced.delete(definition.name);
|
|
2356
|
+
await this.sync(definition);
|
|
2357
|
+
}
|
|
2358
|
+
return (await this.store.listJobs()).map((job) => this.readJob(job));
|
|
2359
|
+
}
|
|
2360
|
+
/** A stored job read leniently (readStoredJob); one whose definition is unreadable is remembered, so that evaluating it throws. */
|
|
2361
|
+
readJob(stored) {
|
|
2362
|
+
const { job, readable } = readStoredJob(stored);
|
|
2363
|
+
if (!readable) this.unreadable.add(job);
|
|
2364
|
+
return job;
|
|
2365
|
+
}
|
|
2366
|
+
/** Throws for a job whose stored definition was not a JSON object: reported, and shown as failing, while the others carry on. */
|
|
2367
|
+
evaluable(stored) {
|
|
2368
|
+
if (this.unreadable.has(stored)) throw new Error(`job "${stored.name}": its stored definition is not a JSON object`);
|
|
2369
|
+
}
|
|
2177
2370
|
/**
|
|
2178
2371
|
* Runs `fn` after every earlier state update for the same job has settled,
|
|
2179
2372
|
* so two runs (or a run and a check) in this process never read and write
|
|
@@ -2249,7 +2442,7 @@ var CronWatch = class {
|
|
|
2249
2442
|
};
|
|
2250
2443
|
let recorded = false;
|
|
2251
2444
|
try {
|
|
2252
|
-
await this.sync(definition);
|
|
2445
|
+
await this.sync(definition, true);
|
|
2253
2446
|
await this.store.insertRun({ ...run });
|
|
2254
2447
|
recorded = true;
|
|
2255
2448
|
} catch (e) {
|
|
@@ -2274,7 +2467,7 @@ var CronWatch = class {
|
|
|
2274
2467
|
run.finishedAt = finishedAt;
|
|
2275
2468
|
run.durationMs = runDuration(startedAt, finishedAt);
|
|
2276
2469
|
run.metrics = recorder.metrics();
|
|
2277
|
-
run.output = recorder.output() ?? (typeof result === "string" ?
|
|
2470
|
+
run.output = recorder.output() ?? (typeof result === "string" ? result : null);
|
|
2278
2471
|
this.conclude(definition, run, result, error, threw, recorder.expectText() ?? (typeof result === "string" ? result : null));
|
|
2279
2472
|
await started;
|
|
2280
2473
|
try {
|
|
@@ -2287,12 +2480,13 @@ var CronWatch = class {
|
|
|
2287
2480
|
}
|
|
2288
2481
|
/**
|
|
2289
2482
|
* Sets a finished run's status and error from how it ended, then redacts
|
|
2290
|
-
* its output and error. Shared by execute()
|
|
2483
|
+
* its output and error and caps them, in that order. Shared by execute()
|
|
2484
|
+
* and RunHandle.finish().
|
|
2291
2485
|
*/
|
|
2292
2486
|
conclude(definition, run, result, error, threw, expectText) {
|
|
2293
2487
|
if (threw) {
|
|
2294
2488
|
run.status = "failed";
|
|
2295
|
-
run.error =
|
|
2489
|
+
run.error = describeError(error);
|
|
2296
2490
|
} else if (result instanceof Response && result.status >= 400) {
|
|
2297
2491
|
run.status = "failed";
|
|
2298
2492
|
run.error = `HTTP ${result.status}${result.statusText ? ` ${result.statusText}` : ""}`;
|
|
@@ -2305,8 +2499,8 @@ var CronWatch = class {
|
|
|
2305
2499
|
run.status = "ok";
|
|
2306
2500
|
}
|
|
2307
2501
|
}
|
|
2308
|
-
if (run.output !== null) run.output =
|
|
2309
|
-
if (run.error !== null) run.error =
|
|
2502
|
+
if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
|
|
2503
|
+
if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
|
|
2310
2504
|
}
|
|
2311
2505
|
/**
|
|
2312
2506
|
* Writes a finished run and evaluates it. `recorded` says whether its
|
|
@@ -2401,7 +2595,7 @@ ${id}`;
|
|
|
2401
2595
|
};
|
|
2402
2596
|
let recorded = false;
|
|
2403
2597
|
try {
|
|
2404
|
-
await this.sync(definition);
|
|
2598
|
+
await this.sync(definition, true);
|
|
2405
2599
|
await this.store.insertRun({ ...run });
|
|
2406
2600
|
recorded = true;
|
|
2407
2601
|
} catch (e) {
|
|
@@ -2501,14 +2695,15 @@ ${id}`;
|
|
|
2501
2695
|
const result = typeof outcome === "string" ? outcome : failed ? void 0 : outcome?.result;
|
|
2502
2696
|
const error = failed ? outcome.error : void 0;
|
|
2503
2697
|
const finishedAt = self.now();
|
|
2504
|
-
const added = recorder.output() ?? (typeof result === "string" ?
|
|
2698
|
+
const added = recorder.output() ?? (typeof result === "string" ? result : null);
|
|
2505
2699
|
const run = {
|
|
2506
2700
|
...from,
|
|
2507
2701
|
status: "running",
|
|
2508
2702
|
finishedAt,
|
|
2509
2703
|
durationMs: runDuration(from.startedAt, finishedAt),
|
|
2510
2704
|
error: null,
|
|
2511
|
-
|
|
2705
|
+
// Capped by conclude(), after it is redacted.
|
|
2706
|
+
output: joinLines(from.output, added),
|
|
2512
2707
|
metrics: { ...from.metrics, ...recorder.metrics() }
|
|
2513
2708
|
};
|
|
2514
2709
|
const expectText = joinLines(head, joinLines(from.output, recorder.expectText() ?? (typeof result === "string" ? result : null)));
|
|
@@ -2557,7 +2752,7 @@ ${id}`;
|
|
|
2557
2752
|
self.report(new Error(`run ${id} of ${name} belongs to job "${stored.job}"; ignored`), `flushing ${name}`);
|
|
2558
2753
|
return;
|
|
2559
2754
|
}
|
|
2560
|
-
const output = lines === null ? stored.output : joinOutput(stored.output,
|
|
2755
|
+
const output = lines === null ? stored.output : joinOutput(stored.output, redactAndCap(lines, self.redact));
|
|
2561
2756
|
if (!await self.writeRunIf({ ...stored, output, metrics: { ...stored.metrics, ...metrics } }, ["running"])) return putBack();
|
|
2562
2757
|
const text = taken.expectText();
|
|
2563
2758
|
if (text !== null && (head === null || head.length < OUTPUT_CAP)) head = (joinLines(head, text) ?? "").slice(0, OUTPUT_CAP);
|
|
@@ -2582,13 +2777,23 @@ ${id}`;
|
|
|
2582
2777
|
* alone and reported. A finished run is judged as if it had been wrapped
|
|
2583
2778
|
* here (expect, failures, duration, budgets) and its output and error are
|
|
2584
2779
|
* redacted the same way; one finishing after a check marked it timeout is
|
|
2585
|
-
* judged only when it succeeded, as RunHandle.finish() does.
|
|
2586
|
-
*
|
|
2780
|
+
* judged only when it succeeded, as RunHandle.finish() does. A metric that
|
|
2781
|
+
* is not a finite number throws before anything is written, as
|
|
2782
|
+
* job.metric() does. Returns the alerts it sent.
|
|
2587
2783
|
*/
|
|
2588
2784
|
async recordRun(input, options = {}) {
|
|
2589
2785
|
const declared = this.definitions.get(input.job);
|
|
2590
2786
|
if (!declared) throw new Error(`recordRun: job "${input.job}" is not declared; call job() first`);
|
|
2787
|
+
if (typeof input.id !== "string" || input.id.length === 0 || input.id.length > MAX_RUN_ID) {
|
|
2788
|
+
const got = typeof input.id === "string" ? `${input.id.length} characters` : typeof input.id;
|
|
2789
|
+
throw new Error(`recordRun: run ids must be 1 to ${MAX_RUN_ID} characters (got ${got}; job "${input.job}")`);
|
|
2790
|
+
}
|
|
2591
2791
|
if (input.id.includes("\0")) throw new Error(`recordRun: run ids cannot contain a NUL character (job "${input.job}")`);
|
|
2792
|
+
for (const [metric, value] of Object.entries(input.metrics ?? {})) {
|
|
2793
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
2794
|
+
throw new Error(`recordRun: metric "${metric}" must be a finite number (job "${input.job}", run "${input.id}")`);
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2592
2797
|
await this.sync(declared);
|
|
2593
2798
|
const run = { ...input, metrics: { ...input.metrics } };
|
|
2594
2799
|
if (run.status === "ok") {
|
|
@@ -2598,8 +2803,8 @@ ${id}`;
|
|
|
2598
2803
|
run.error = unmet;
|
|
2599
2804
|
}
|
|
2600
2805
|
}
|
|
2601
|
-
if (run.output !== null) run.output =
|
|
2602
|
-
if (run.error !== null) run.error =
|
|
2806
|
+
if (run.output !== null) run.output = redactAndCap(run.output, this.redact);
|
|
2807
|
+
if (run.error !== null) run.error = redactAndCap(run.error, this.redact);
|
|
2603
2808
|
const evaluate = options.evaluate !== false;
|
|
2604
2809
|
const definition = toStored(declared);
|
|
2605
2810
|
const stored = await this.store.getRun(run.id);
|
|
@@ -2633,22 +2838,42 @@ ${id}`;
|
|
|
2633
2838
|
}
|
|
2634
2839
|
/**
|
|
2635
2840
|
* Evaluate a finished run (ok, failed, or timed out by a check), already
|
|
2636
|
-
* written, against the job's state and send what that produces.
|
|
2841
|
+
* written, against the job's state and send what that produces. The
|
|
2842
|
+
* alerts are written with that state (see outbox()). Never throws.
|
|
2637
2843
|
*/
|
|
2638
2844
|
async finishRun(definition, run, now) {
|
|
2639
|
-
let
|
|
2845
|
+
let held;
|
|
2640
2846
|
try {
|
|
2641
2847
|
let history = null;
|
|
2642
|
-
({ result:
|
|
2848
|
+
({ result: held } = await this.updateState(run.job, async (previous) => {
|
|
2643
2849
|
history ??= await this.history(run);
|
|
2644
|
-
|
|
2645
|
-
return { state: settled.state, result: settled.alerts };
|
|
2850
|
+
return this.outbox(applySilence(previous, onRunFinish(definition, run, previous, history, now), now), definition, now);
|
|
2646
2851
|
}));
|
|
2647
2852
|
} catch (e) {
|
|
2648
2853
|
this.onError(e, `evaluating ${run.job}`);
|
|
2649
2854
|
return [];
|
|
2650
2855
|
}
|
|
2651
|
-
|
|
2856
|
+
this.reportDropped(run.job, held.dropped);
|
|
2857
|
+
return this.dispatch(run.job, held.alerts, now);
|
|
2858
|
+
}
|
|
2859
|
+
/**
|
|
2860
|
+
* An evaluation as it is written: its drafts composed into alerts and held
|
|
2861
|
+
* in the same state (holdAlerts), so the write that opens a condition also
|
|
2862
|
+
* keeps its alerts, and a process that stops before sending them does not
|
|
2863
|
+
* lose them. Called inside updateState(), so it only computes.
|
|
2864
|
+
*/
|
|
2865
|
+
outbox(settled, definition, now) {
|
|
2866
|
+
const alerts = settled.alerts.map((draft) => composeAlert(draft, definition, now));
|
|
2867
|
+
const { state, dropped } = holdAlerts(settled.state, alerts, this.now() + SEND_LEASE_MS, this.deferDelivery);
|
|
2868
|
+
return { state, result: { alerts, dropped } };
|
|
2869
|
+
}
|
|
2870
|
+
/** Reports alerts let go because a job's queue was full. */
|
|
2871
|
+
reportDropped(name, dropped) {
|
|
2872
|
+
if (dropped <= 0) return;
|
|
2873
|
+
this.report(
|
|
2874
|
+
new Error(`${dropped} undelivered alert${dropped === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
|
|
2875
|
+
`alert queue for ${name}`
|
|
2876
|
+
);
|
|
2652
2877
|
}
|
|
2653
2878
|
/**
|
|
2654
2879
|
* The runs before `run`, newest first, with up to BASELINE_WINDOW
|
|
@@ -2688,11 +2913,16 @@ ${id}`;
|
|
|
2688
2913
|
}
|
|
2689
2914
|
for (const definition of this.definitions.values()) await this.sync(definition);
|
|
2690
2915
|
const now = this.now();
|
|
2691
|
-
for (const
|
|
2916
|
+
for (const listed of await this.store.runningRuns()) {
|
|
2692
2917
|
try {
|
|
2693
|
-
const declared = this.definitions.get(
|
|
2694
|
-
const
|
|
2695
|
-
|
|
2918
|
+
const declared = this.definitions.get(listed.job);
|
|
2919
|
+
const found = declared ? null : await this.store.getJob(listed.job);
|
|
2920
|
+
const stored = found ? this.readJob(found) : null;
|
|
2921
|
+
if (stored) this.evaluable(stored);
|
|
2922
|
+
const definition = declared ? toStored(declared) : stored?.definition;
|
|
2923
|
+
if (!definition || !isStuck(definition, listed, now)) continue;
|
|
2924
|
+
const run = await this.store.getRun(listed.id);
|
|
2925
|
+
if (!run || run.status !== "running" || run.job !== listed.job) continue;
|
|
2696
2926
|
const timeout = timeoutMs(definition);
|
|
2697
2927
|
run.status = "timeout";
|
|
2698
2928
|
run.finishedAt = now;
|
|
@@ -2701,23 +2931,27 @@ ${id}`;
|
|
|
2701
2931
|
if (!await this.writeRunIf(run, ["running"])) continue;
|
|
2702
2932
|
alerts.push(...await this.finishRun(definition, run, now));
|
|
2703
2933
|
} catch (e) {
|
|
2704
|
-
this.onError(e, `checking ${
|
|
2934
|
+
this.onError(e, `checking ${listed.job}`);
|
|
2705
2935
|
}
|
|
2706
2936
|
}
|
|
2707
2937
|
const jobs = [];
|
|
2708
2938
|
const retries = { spentMs: 0 };
|
|
2709
|
-
for (const stored of await this.
|
|
2939
|
+
for (const stored of await this.storedJobs()) {
|
|
2710
2940
|
try {
|
|
2941
|
+
this.evaluable(stored);
|
|
2711
2942
|
const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
|
|
2712
2943
|
let nextExpectedAt = null;
|
|
2713
|
-
const { state, result:
|
|
2944
|
+
const { state, result: held } = await this.updateState(stored.name, (previous) => {
|
|
2714
2945
|
const evaluation = onCheck(stored.definition, stored, recent[0] ?? null, previous, now);
|
|
2715
2946
|
nextExpectedAt = evaluation.nextExpectedAt;
|
|
2716
2947
|
const settled = applySilence(previous, evaluation, now);
|
|
2717
|
-
|
|
2948
|
+
const released = releaseSending(settled.state, this.now());
|
|
2949
|
+
const out = this.outbox({ state: released.state, alerts: settled.alerts }, stored.definition, now);
|
|
2950
|
+
return { state: out.state, result: { alerts: out.result.alerts, dropped: released.dropped + out.result.dropped } };
|
|
2718
2951
|
});
|
|
2952
|
+
this.reportDropped(stored.name, held.dropped);
|
|
2719
2953
|
alerts.push(...await this.retryUndelivered(stored.name, state, now, retries));
|
|
2720
|
-
alerts.push(...await this.dispatch(
|
|
2954
|
+
alerts.push(...await this.dispatch(stored.name, held.alerts, now));
|
|
2721
2955
|
jobs.push(summarize(stored, recent, state, nextExpectedAt, now));
|
|
2722
2956
|
} catch (e) {
|
|
2723
2957
|
this.onError(e, `checking ${stored.name}`);
|
|
@@ -2740,6 +2974,7 @@ ${id}`;
|
|
|
2740
2974
|
let recent = [];
|
|
2741
2975
|
try {
|
|
2742
2976
|
recent = await this.store.listRuns(stored.name, Math.max(runs, BASELINE_WINDOW));
|
|
2977
|
+
this.evaluable(stored);
|
|
2743
2978
|
const state = await this.readState(stored.name);
|
|
2744
2979
|
const { nextExpectedAt } = onCheck(stored.definition, stored, recent[0] ?? null, state, now);
|
|
2745
2980
|
return { job: summarize(stored, recent, state, nextExpectedAt, now), runs: recent.slice(0, runs) };
|
|
@@ -2761,19 +2996,20 @@ ${id}`;
|
|
|
2761
2996
|
/** Every job's summary with its newest `limit` runs, read together. What the dashboard shows. */
|
|
2762
2997
|
async jobsWithRuns(limit = 20) {
|
|
2763
2998
|
await this.ensureReady();
|
|
2764
|
-
|
|
2999
|
+
const jobs = await this.storedJobs();
|
|
2765
3000
|
const now = this.now();
|
|
2766
3001
|
const out = [];
|
|
2767
|
-
for (const stored of
|
|
3002
|
+
for (const stored of jobs) out.push(await this.snapshot(stored, now, clampLimit(limit, 20, 0)));
|
|
2768
3003
|
return out;
|
|
2769
3004
|
}
|
|
3005
|
+
/** A job's summary, or null for one the store does not have. One declared here and forgotten elsewhere is written again, as storedJobs() does. */
|
|
2770
3006
|
async jobSummary(name) {
|
|
2771
3007
|
await this.ensureReady();
|
|
2772
3008
|
const definition = this.definitions.get(name);
|
|
2773
|
-
if (definition) await this.sync(definition);
|
|
3009
|
+
if (definition) await this.sync(definition, true);
|
|
2774
3010
|
const stored = await this.store.getJob(name);
|
|
2775
3011
|
if (!stored) return null;
|
|
2776
|
-
return (await this.snapshot(stored, this.now(), 0)).job;
|
|
3012
|
+
return (await this.snapshot(this.readJob(stored), this.now(), 0)).job;
|
|
2777
3013
|
}
|
|
2778
3014
|
/** A job's runs, newest first. `limit` is a whole number from 1 to 500. */
|
|
2779
3015
|
async runs(name, limit = 50) {
|
|
@@ -2784,11 +3020,14 @@ ${id}`;
|
|
|
2784
3020
|
await this.ensureReady();
|
|
2785
3021
|
return this.store.getRun(id);
|
|
2786
3022
|
}
|
|
2787
|
-
/**
|
|
3023
|
+
/**
|
|
3024
|
+
* Stop alerts for a job for a while. State keeps updating underneath. The
|
|
3025
|
+
* end is a whole millisecond, held at 2^53 - 1 (see silenceEnd).
|
|
3026
|
+
*/
|
|
2788
3027
|
async silence(name, duration) {
|
|
2789
3028
|
const ms = parseDuration(duration, "silence duration");
|
|
2790
3029
|
return this.patchState(name, (state) => {
|
|
2791
|
-
state.silencedUntil = this.now()
|
|
3030
|
+
state.silencedUntil = silenceEnd(this.now(), ms);
|
|
2792
3031
|
});
|
|
2793
3032
|
}
|
|
2794
3033
|
async unsilence(name) {
|
|
@@ -2806,28 +3045,38 @@ ${id}`;
|
|
|
2806
3045
|
});
|
|
2807
3046
|
return state;
|
|
2808
3047
|
}
|
|
2809
|
-
/**
|
|
3048
|
+
/**
|
|
3049
|
+
* Remove a job and its runs from the store. A job still declared in code
|
|
3050
|
+
* comes back: here on its next run, and in any other process that declares
|
|
3051
|
+
* it on its next run there, or at that process's next check or dashboard read.
|
|
3052
|
+
*/
|
|
2810
3053
|
async forget(name) {
|
|
2811
3054
|
await this.ensureReady();
|
|
2812
3055
|
this.definitions.delete(name);
|
|
2813
3056
|
this.synced.delete(name);
|
|
2814
3057
|
await this.store.deleteJob(name);
|
|
2815
3058
|
}
|
|
2816
|
-
/**
|
|
3059
|
+
/**
|
|
3060
|
+
* The dashboard and JSON API as fetch-style handlers: the one way to mount
|
|
3061
|
+
* them. A Next.js app exports them from app/cronwatch/[[...path]]/route.ts:
|
|
3062
|
+
*
|
|
3063
|
+
* export const { GET, POST, DELETE } = cw.routes();
|
|
3064
|
+
*/
|
|
2817
3065
|
routes(options) {
|
|
2818
|
-
return
|
|
3066
|
+
return buildRoutes(this, options);
|
|
2819
3067
|
}
|
|
2820
3068
|
/**
|
|
2821
3069
|
* Check on an interval, for long-running servers. Default every minute.
|
|
2822
3070
|
* Not for serverless functions or Cloudflare Workers, where nothing runs
|
|
2823
|
-
* between requests: call check() from a cron there instead.
|
|
3071
|
+
* between requests: call check() from a cron there instead. (A job's
|
|
3072
|
+
* start() opens a run; this starts the checks.)
|
|
2824
3073
|
*/
|
|
2825
|
-
|
|
3074
|
+
startChecking(every = "1m") {
|
|
2826
3075
|
if (this.timer) return;
|
|
2827
3076
|
const ms = Math.min(TIMER_MAX_MS, Math.max(5e3, parseDuration(every, "check interval")));
|
|
2828
3077
|
if (this.deferDelivery && !this.warnedDeferredStart) {
|
|
2829
3078
|
this.warnedDeferredStart = true;
|
|
2830
|
-
console.warn('[cronwatch]
|
|
3079
|
+
console.warn('[cronwatch] startChecking() was called with deliver: "check", so these checks send no alerts. Another process must run checks with deliver: "now" (the default) to send them.');
|
|
2831
3080
|
}
|
|
2832
3081
|
const tick = () => this.check().catch((e) => this.onError(e, "check"));
|
|
2833
3082
|
this.timer = setInterval(tick, ms);
|
|
@@ -2838,38 +3087,52 @@ ${id}`;
|
|
|
2838
3087
|
}, 1e3);
|
|
2839
3088
|
if (typeof this.firstTick === "object" && "unref" in this.firstTick) this.firstTick.unref();
|
|
2840
3089
|
}
|
|
3090
|
+
/**
|
|
3091
|
+
* startChecking(), under its former name.
|
|
3092
|
+
* @deprecated Renamed `startChecking`, since a job's `start()` opens a run.
|
|
3093
|
+
* This name still works through 1.x and goes in 2.0.
|
|
3094
|
+
*/
|
|
3095
|
+
start(every = "1m") {
|
|
3096
|
+
this.startChecking(every);
|
|
3097
|
+
}
|
|
3098
|
+
/** Stop the checks startChecking() began. */
|
|
2841
3099
|
stop() {
|
|
2842
3100
|
if (this.timer) clearInterval(this.timer);
|
|
2843
3101
|
if (this.firstTick) clearTimeout(this.firstTick);
|
|
2844
3102
|
this.timer = null;
|
|
2845
3103
|
this.firstTick = null;
|
|
2846
3104
|
}
|
|
3105
|
+
/**
|
|
3106
|
+
* Stop the interval, wait for a check already under way (bounded by its
|
|
3107
|
+
* own channel, triage and retry timeouts; what it throws was reported to
|
|
3108
|
+
* whoever started it), then close the store, so that check neither writes
|
|
3109
|
+
* after the store is closed nor loses the alerts it would queue.
|
|
3110
|
+
*/
|
|
2847
3111
|
async close() {
|
|
2848
3112
|
this.stop();
|
|
3113
|
+
if (this.checking) await this.checking.catch(() => {
|
|
3114
|
+
});
|
|
2849
3115
|
if (this.store.close) await this.store.close();
|
|
2850
3116
|
}
|
|
2851
3117
|
/**
|
|
2852
|
-
*
|
|
2853
|
-
*
|
|
2854
|
-
* the delivery fields are written back, onto
|
|
3118
|
+
* Triage and send each alert the outbox holds (see outbox()). The state,
|
|
3119
|
+
* with the alerts in it, was saved before this, so a slow channel holds up
|
|
3120
|
+
* nothing else; afterwards only the delivery fields are written back, onto
|
|
3121
|
+
* a fresh read of the state, and the alerts leave `sending`. Triage is made
|
|
3122
|
+
* here, never stored with the held alert: the write that opens a condition
|
|
3123
|
+
* cannot wait for it, and a retry triages an alert that has none. With
|
|
3124
|
+
* deliver: "check" the alerts were queued for a check elsewhere instead.
|
|
2855
3125
|
*/
|
|
2856
|
-
async dispatch(
|
|
2857
|
-
if (
|
|
2858
|
-
const composed = [];
|
|
3126
|
+
async dispatch(name, alerts, now) {
|
|
3127
|
+
if (alerts.length === 0 || this.deferDelivery) return alerts;
|
|
2859
3128
|
const delivered = [];
|
|
2860
3129
|
const failed = [];
|
|
2861
|
-
for (const
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
failed.push(alert);
|
|
2865
|
-
} else {
|
|
2866
|
-
if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
|
|
2867
|
-
(await this.deliver(alert) ? delivered : failed).push(alert);
|
|
2868
|
-
}
|
|
2869
|
-
composed.push(alert);
|
|
3130
|
+
for (const alert of alerts) {
|
|
3131
|
+
if (this.triage && alert.type !== "recovered") await this.addTriage(alert, TRIAGE_TIMEOUT_MS);
|
|
3132
|
+
(await this.deliver(alert) ? delivered : failed).push(alert);
|
|
2870
3133
|
}
|
|
2871
|
-
await this.recordDelivery(
|
|
2872
|
-
return
|
|
3134
|
+
await this.recordDelivery(name, delivered, failed, [], now);
|
|
3135
|
+
return alerts;
|
|
2873
3136
|
}
|
|
2874
3137
|
/**
|
|
2875
3138
|
* Send the alerts that no channel accepted last time, once each, oldest
|
|
@@ -2898,28 +3161,18 @@ ${id}`;
|
|
|
2898
3161
|
}
|
|
2899
3162
|
/**
|
|
2900
3163
|
* Mark delivered alerts done, drop stale ones, and keep failed ones for the
|
|
2901
|
-
* next check
|
|
2902
|
-
*
|
|
3164
|
+
* next check, taking them all out of `sending` (recordSent). A failed alert
|
|
3165
|
+
* replaces its stored copy, so a triage made on this attempt is kept.
|
|
3166
|
+
* lastAlertAt moves only on a delivery. When this write fails, alerts
|
|
3167
|
+
* still in `sending` are retried once their lease runs out.
|
|
2903
3168
|
*/
|
|
2904
3169
|
async recordDelivery(name, delivered, failed, dropped, now) {
|
|
2905
3170
|
try {
|
|
2906
3171
|
const { result: trimmed } = await this.updateState(name, (previous) => {
|
|
2907
|
-
const state = normalizeState(previous, name);
|
|
2908
|
-
|
|
2909
|
-
const retried = new Map(failed.map((a) => [alertKey(a), a]));
|
|
2910
|
-
const kept = state.undelivered.filter((a) => !done.has(alertKey(a))).map((a) => retried.get(alertKey(a)) ?? a);
|
|
2911
|
-
const known = new Set(kept.map(alertKey));
|
|
2912
|
-
kept.push(...failed.filter((a) => !known.has(alertKey(a))));
|
|
2913
|
-
state.undelivered = kept.slice(-MAX_UNDELIVERED);
|
|
2914
|
-
if (delivered.length > 0) state.lastAlertAt = now;
|
|
2915
|
-
return { state, result: Math.max(0, kept.length - MAX_UNDELIVERED) };
|
|
3172
|
+
const { state, dropped: letGo } = recordSent(normalizeState(previous, name), delivered, failed, dropped, now);
|
|
3173
|
+
return { state, result: letGo };
|
|
2916
3174
|
});
|
|
2917
|
-
|
|
2918
|
-
this.onError(
|
|
2919
|
-
new Error(`${trimmed} undelivered alert${trimmed === 1 ? "" : "s"} for ${name} dropped: only the newest ${MAX_UNDELIVERED} are kept for retry`),
|
|
2920
|
-
`alert queue for ${name}`
|
|
2921
|
-
);
|
|
2922
|
-
}
|
|
3175
|
+
this.reportDropped(name, trimmed);
|
|
2923
3176
|
} catch (e) {
|
|
2924
3177
|
this.onError(e, `recording alert delivery for ${name}`);
|
|
2925
3178
|
}
|
|
@@ -2955,9 +3208,10 @@ ${id}`;
|
|
|
2955
3208
|
}
|
|
2956
3209
|
};
|
|
2957
3210
|
var RESERVED_RUN_ID_PREFIX = "pgcron:";
|
|
3211
|
+
var MAX_RUN_ID = 200;
|
|
2958
3212
|
function checkRunId(job, id, method) {
|
|
2959
|
-
if (typeof id !== "string" || id.length === 0 || id.length >
|
|
2960
|
-
throw new Error(`job "${job}": ${method}() needs a run id of 1 to
|
|
3213
|
+
if (typeof id !== "string" || id.length === 0 || id.length > MAX_RUN_ID) {
|
|
3214
|
+
throw new Error(`job "${job}": ${method}() needs a run id of 1 to ${MAX_RUN_ID} characters (got ${typeof id === "string" ? `${id.length} characters` : typeof id})`);
|
|
2961
3215
|
}
|
|
2962
3216
|
if (id.includes("\0")) throw new Error(`job "${job}": ${method}() cannot take a run id containing a NUL character`);
|
|
2963
3217
|
if (id.startsWith(RESERVED_RUN_ID_PREFIX)) {
|
|
@@ -3018,12 +3272,13 @@ function custom(name, send) {
|
|
|
3018
3272
|
await send(alert);
|
|
3019
3273
|
} };
|
|
3020
3274
|
}
|
|
3275
|
+
var CronWatch = Cronwatch;
|
|
3021
3276
|
|
|
3022
3277
|
// src/index.ts
|
|
3023
3278
|
function cronwatch(options = {}) {
|
|
3024
|
-
return new
|
|
3279
|
+
return new Cronwatch(options);
|
|
3025
3280
|
}
|
|
3026
3281
|
|
|
3027
|
-
export { CronWatch, composeAlert, consoleChannel, createRoutes, cronwatch, custom, formatDuration, memory, nextFire, parseDuration, parseSchedule };
|
|
3282
|
+
export { CronWatch, Cronwatch, VERSION, composeAlert, consoleChannel, createRoutes, cronwatch, custom, formatDuration, memory, nextFire, parseDuration, parseSchedule };
|
|
3028
3283
|
//# sourceMappingURL=index.js.map
|
|
3029
3284
|
//# sourceMappingURL=index.js.map
|