@cronwatch/sdk 0.10.0 → 0.11.1
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-DUoSea7X.d.cts → client-CRMfhylq.d.cts} +63 -26
- package/dist/{client-BcIjVBiZ.d.ts → client-D2Xk2600.d.ts} +63 -26
- 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 +10 -4
- package/dist/discord.cjs.map +1 -1
- package/dist/discord.d.cts +13 -6
- package/dist/discord.d.ts +13 -6
- package/dist/discord.js +7 -1
- package/dist/discord.js.map +1 -1
- package/dist/{email-VrGsVz43.d.ts → email-BWuUiCkw.d.ts} +1 -1
- package/dist/{email-JZs3vxwq.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 +194 -40
- 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 +193 -41
- 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 +17 -7
- 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 +14 -4
- 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-DyWCxlSi.d.cts → types-DLnlUie8.d.cts} +5 -2
- package/dist/{types-DyWCxlSi.d.ts → types-DLnlUie8.d.ts} +5 -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/discord.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/discord.ts"],"names":[],"mappings":"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","file":"discord.js","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport { isoTime } from \"../duration.js\";\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetch(postable(url), { method: \"POST\", headers: init.headers, body: init.body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n if (!response.ok) {\n let text = \"\";\n try {\n text = await response.text();\n } catch {\n // The status is enough.\n }\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n return response;\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. A start before the year 1 or after 9999 is null. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: isoTime(run.startedAt), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","import type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, postable } from \"./shared.js\";\n\nexport interface DiscordOptions {\n /** A channel webhook URL from Server Settings, Integrations, Webhooks. */\n webhookUrl: string;\n link?: (alert: Alert) => string;\n}\n\nconst COLOR: Record<Alert[\"type\"], number> = {\n missed: 0xb7791f,\n failed: 0xc62828,\n stuck: 0xc62828,\n slow: 0xb7791f,\n over_budget: 0xb7791f,\n recovered: 0x1f8a4c,\n};\n\nconst TIMEOUT_MS = 10_000;\n\n/** The longest embed description Discord takes. The title (under 256) and it stay well inside the embed's 6000. */\nexport const DESCRIPTION_MAX = 4096;\n\n/** Sends alerts to a Discord channel through a webhook. */\nexport function discord(options: DiscordOptions): AlertChannel {\n if (!options.webhookUrl) throw new Error(\"discord() needs a webhookUrl\");\n return {\n name: \"discord\",\n async send(alert) {\n const url = options.link?.(alert);\n const response = await fetch(postable(options.webhookUrl), {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n // Refused, not followed: a webhook URL is its own credential.\n redirect: \"error\",\n signal: AbortSignal.timeout(TIMEOUT_MS),\n body: JSON.stringify({\n content: alert.title,\n // Job output can hold anything, \"@everyone\" included; ping no one.\n allowed_mentions: { parse: [] },\n embeds: [\n {\n title: alert.title,\n ...(url ? { url } : {}),\n description: embedDescription(alert),\n color: COLOR[alert.type],\n timestamp: new Date(alert.at).toISOString(),\n },\n ],\n }),\n });\n if (!response.ok) throw new Error(`Discord webhook answered ${response.status}: ${(await response.text()).slice(0, 200)}`);\n },\n };\n}\n\n/**\n * The message in a code block, then the triage. Each part has its own cap,\n * and escaping can grow both, so the whole is held to DESCRIPTION_MAX (in\n * UTF-16 units) by cutting the message's block, never the triage: Discord\n * refuses a longer one on every retry.\n */\nexport function embedDescription(alert: Alert): string {\n const triage = alert.triage ? `\\n**Triage:** ${escapeMarkdown(alert.triage.slice(0, 1000))}` : \"\";\n const fences = \"```\\n\".length + \"\\n```\".length;\n return \"```\\n\" + cut(codeBlockSafe(alert.message.slice(0, 3800)), DESCRIPTION_MAX - fences - triage.length) + \"\\n```\" + triage;\n}\n\n/** Breaks up ``` so text inside a code block cannot close it. */\nexport function codeBlockSafe(text: string): string {\n return text.replace(/```/g, \"```\");\n}\n\n/** Escapes the characters Discord reads as markdown, links included. */\nexport function escapeMarkdown(text: string): string {\n return text.replace(/[\\\\`*_~|[\\]()<>]/g, \"\\\\$&\");\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/discord.ts","../src/entries/discord.ts"],"names":["DESCRIPTION_MAX","embedDescription","codeBlockSafe","escapeMarkdown"],"mappings":"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","file":"discord.js","sourcesContent":["/**\n * Helpers shared by the provider channels. Only fetch and Web Crypto, so the\n * channels run on Node, Cloudflare Workers, Deno and Bun alike. Not an entry\n * point: each channel bundles its own copy.\n */\nimport { isoTime } from \"../duration.js\";\nimport type { Alert } from \"../types.js\";\n\nexport const TIMEOUT_MS = 10_000;\n\n/** Severity for trackers that have levels. Recovered is informational. */\nexport type Severity = \"error\" | \"warning\" | \"info\";\n\nexport function severity(type: Alert[\"type\"]): Severity {\n if (type === \"recovered\") return \"info\";\n if (type === \"slow\" || type === \"over_budget\") return \"warning\";\n return \"error\";\n}\n\n/** The scheme, host and port only. A URL's path or query can hold a credential. */\nexport function origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * The URL, once it is one fetch can post to. Refused without quoting it:\n * fetch's own error for a URL it cannot parse (\"Failed to parse URL from ...\")\n * quotes the whole URL, and a webhook URL's path is its credential.\n */\nexport function postable(url: string): string {\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (protocol !== \"http:\" && protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);\n return url;\n}\n\n/** How much of a provider's error body goes into the error message. */\nexport const ERROR_BODY_MAX = 200;\n\n/**\n * POSTs and throws on a non-2xx answer. The error names the provider and the\n * URL's origin, plus the start of the response body with every secret the\n * channel holds cut out, in case a provider echoes one back. Secrets are cut\n * from a prefix long enough to hold one that starts inside the first\n * ERROR_BODY_MAX characters, and only then is it cut to that length, so no\n * part of a secret survives at the edge. A redirect is refused rather than\n * followed: the credential headers would go with it to wherever it points.\n */\nexport async function post(provider: string, url: string, init: { headers: Record<string, string>; body: string }, secrets: (string | undefined)[] = []): Promise<Response> {\n const response = await fetch(postable(url), { method: \"POST\", headers: init.headers, body: init.body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n if (!response.ok) {\n let text = \"\";\n try {\n text = await response.text();\n } catch {\n // The status is enough.\n }\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n return response;\n}\n\n/** The start of an error body, secrets cut out first, then cut to ERROR_BODY_MAX on a code point. */\nexport function errorBody(text: string, secrets: (string | undefined)[] = []): string {\n const kept = secrets.filter((s): s is string => typeof s === \"string\" && s.length >= 4);\n const longest = kept.reduce((n, s) => Math.max(n, s.length), 0);\n let head = cut(text, ERROR_BODY_MAX + longest);\n for (const secret of kept) head = head.split(secret).join(\"[redacted]\");\n return cut(head, ERROR_BODY_MAX);\n}\n\n/** A credential with the spaces and newlines a paste leaves around it taken off. Anything not a string is empty. */\nexport function trimmed(value: unknown): string {\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\n/** Base64 of UTF-8, without Buffer. */\nexport function base64(text: string): string {\n let binary = \"\";\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\nexport function basicAuth(user: string, password: string): string {\n return `Basic ${base64(`${user}:${password}`)}`;\n}\n\nexport function hex(bytes: ArrayBuffer | Uint8Array): string {\n return Array.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nexport async function sha256Hex(text: string): Promise<string> {\n return hex(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text)));\n}\n\n/**\n * A stable 32 hex character id for one alert: the same job, type and time\n * always give the same id, so a provider that deduplicates on it drops a\n * resend of an alert it already took.\n */\nexport async function alertId(alert: Alert): Promise<string> {\n return (await sha256Hex(`${alert.job}\\n${alert.type}\\n${alert.at}`)).slice(0, 32);\n}\n\n/** The same id laid out as a UUID, for APIs that ask for one. */\nexport function asUuid(id: string): string {\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20, 32)}`;\n}\n\n/** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */\nexport function cut(text: string, max: number): string {\n if (text.length <= max) return text;\n let end = max;\n const code = text.charCodeAt(end - 1);\n if (code >= 0xd800 && code <= 0xdbff) end -= 1;\n return text.slice(0, end);\n}\n\n/** The run fields worth attaching to a tracker event. A start before the year 1 or after 9999 is null. */\nexport function runSummary(alert: Alert): Record<string, unknown> | null {\n const run = alert.run;\n if (!run) return null;\n return { id: run.id, status: run.status, startedAt: isoTime(run.startedAt), durationMs: run.durationMs, trigger: run.trigger };\n}\n\n/** Title, message, triage and link as one plain text block, the way every channel reads. */\nexport function plainText(alert: Alert, link: string | undefined): string {\n return [alert.title, \"\", alert.message, ...(alert.triage ? [\"\", `Triage: ${alert.triage}`] : []), ...(link ? [\"\", `Open: ${link}`] : [])].join(\"\\n\");\n}\n","import type { Alert, AlertChannel } from \"../types.js\";\nimport { cut, postable } from \"./shared.js\";\n\nexport interface DiscordOptions {\n /** A channel webhook URL from Server Settings, Integrations, Webhooks. */\n webhookUrl: string;\n link?: (alert: Alert) => string;\n}\n\nconst COLOR: Record<Alert[\"type\"], number> = {\n missed: 0xb7791f,\n failed: 0xc62828,\n stuck: 0xc62828,\n slow: 0xb7791f,\n over_budget: 0xb7791f,\n recovered: 0x1f8a4c,\n};\n\nconst TIMEOUT_MS = 10_000;\n\n/** The longest embed description Discord takes. The title (under 256) and it stay well inside the embed's 6000. */\nexport const DESCRIPTION_MAX = 4096;\n\n/** Sends alerts to a Discord channel through a webhook. */\nexport function discord(options: DiscordOptions): AlertChannel {\n if (!options.webhookUrl) throw new Error(\"discord() needs a webhookUrl\");\n return {\n name: \"discord\",\n async send(alert) {\n const url = options.link?.(alert);\n const response = await fetch(postable(options.webhookUrl), {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n // Refused, not followed: a webhook URL is its own credential.\n redirect: \"error\",\n signal: AbortSignal.timeout(TIMEOUT_MS),\n body: JSON.stringify({\n content: alert.title,\n // Job output can hold anything, \"@everyone\" included; ping no one.\n allowed_mentions: { parse: [] },\n embeds: [\n {\n title: alert.title,\n ...(url ? { url } : {}),\n description: embedDescription(alert),\n color: COLOR[alert.type],\n timestamp: new Date(alert.at).toISOString(),\n },\n ],\n }),\n });\n if (!response.ok) throw new Error(`Discord webhook answered ${response.status}: ${(await response.text()).slice(0, 200)}`);\n },\n };\n}\n\n/**\n * The message in a code block, then the triage. Each part has its own cap,\n * and escaping can grow both, so the whole is held to DESCRIPTION_MAX (in\n * UTF-16 units) by cutting the message's block, never the triage: Discord\n * refuses a longer one on every retry.\n */\nexport function embedDescription(alert: Alert): string {\n const triage = alert.triage ? `\\n**Triage:** ${escapeMarkdown(alert.triage.slice(0, 1000))}` : \"\";\n const fences = \"```\\n\".length + \"\\n```\".length;\n return \"```\\n\" + cut(codeBlockSafe(alert.message.slice(0, 3800)), DESCRIPTION_MAX - fences - triage.length) + \"\\n```\" + triage;\n}\n\n/** Breaks up ``` so text inside a code block cannot close it. */\nexport function codeBlockSafe(text: string): string {\n return text.replace(/```/g, \"```\");\n}\n\n/** Escapes the characters Discord reads as markdown, links included. */\nexport function escapeMarkdown(text: string): string {\n return text.replace(/[\\\\`*_~|[\\]()<>]/g, \"\\\\$&\");\n}\n","// @cronwatch/sdk/discord: what the entry point exports. The helpers the\n// channel is built from are internal; the names below are kept, deprecated,\n// for anyone who imported them, and are no longer exported from 1.0.\nimport * as internal from \"../alerts/discord.js\";\n\nexport { discord } from \"../alerts/discord.js\";\nexport type { DiscordOptions } from \"../alerts/discord.js\";\n\n/** @deprecated Internal to the Discord channel; no longer exported from 1.0. */\nexport const DESCRIPTION_MAX = internal.DESCRIPTION_MAX;\n/** @deprecated Internal to the Discord channel; no longer exported from 1.0. */\nexport const embedDescription = internal.embedDescription;\n/** @deprecated Internal to the Discord channel; no longer exported from 1.0. */\nexport const codeBlockSafe = internal.codeBlockSafe;\n/** @deprecated Internal to the Discord channel; no longer exported from 1.0. */\nexport const escapeMarkdown = internal.escapeMarkdown;\n"]}
|
package/dist/honeybadger.d.cts
CHANGED
package/dist/honeybadger.d.ts
CHANGED
package/dist/index.cjs
CHANGED
|
@@ -80,13 +80,46 @@ function beyondDates(at) {
|
|
|
80
80
|
function readEnv(name) {
|
|
81
81
|
return typeof process === "undefined" || !process.env ? void 0 : process.env[name];
|
|
82
82
|
}
|
|
83
|
+
function readSecretEnv(name) {
|
|
84
|
+
const value = readEnv(name);
|
|
85
|
+
return value === void 0 || value.trim() === "" ? void 0 : value;
|
|
86
|
+
}
|
|
87
|
+
function secretOption(value, what) {
|
|
88
|
+
if (value === void 0 || value === null) return value;
|
|
89
|
+
if (typeof value !== "string") {
|
|
90
|
+
throw new TypeError(`${what} must be a string, or null to opt out, not ${Array.isArray(value) ? "an array" : typeof value}`);
|
|
91
|
+
}
|
|
92
|
+
return value.trim() === "" ? void 0 : value;
|
|
93
|
+
}
|
|
94
|
+
function environment() {
|
|
95
|
+
if (typeof process === "undefined" || !process.env) return "";
|
|
96
|
+
for (const value of [process.env.CRONWATCH_ENV, process.env.APP_ENV, process.env.NODE_ENV]) {
|
|
97
|
+
const name = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
98
|
+
if (name === "") continue;
|
|
99
|
+
if (name === "prod") return "production";
|
|
100
|
+
if (name === "dev" || name === "local" || name === "test" || name === "testing") return "development";
|
|
101
|
+
return name;
|
|
102
|
+
}
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
83
105
|
function isDevelopment() {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
106
|
+
return environment() === "development";
|
|
107
|
+
}
|
|
108
|
+
function isProduction() {
|
|
109
|
+
return environment() === "production";
|
|
87
110
|
}
|
|
88
111
|
var cache = /* @__PURE__ */ new Map();
|
|
89
112
|
var crons = /* @__PURE__ */ new WeakMap();
|
|
113
|
+
var never = /* @__PURE__ */ new WeakSet();
|
|
114
|
+
var PROBE_FROM_MS = Date.UTC(2e3, 0, 1);
|
|
115
|
+
function neverFires(cron) {
|
|
116
|
+
try {
|
|
117
|
+
return cron.nextRuns(1, new Date(PROBE_FROM_MS)).length === 0;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return error instanceof RangeError && /call stack/i.test(error.message);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
var ONE_TIME_DATE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
|
|
90
123
|
var EARLY_SLACK_MS = 6e4;
|
|
91
124
|
function parseSchedule(schedule, timezone) {
|
|
92
125
|
const key = `${timezone ?? ""}|${schedule}`;
|
|
@@ -100,6 +133,10 @@ function parseSchedule(schedule, timezone) {
|
|
|
100
133
|
if (everyMs < 1e3) throw new Error(`schedule "${schedule}" is shorter than one second`);
|
|
101
134
|
parsed = { kind: "interval", source: text, everyMs };
|
|
102
135
|
} else {
|
|
136
|
+
if (text.slice(1).includes(":")) {
|
|
137
|
+
const reason = ONE_TIME_DATE.test(text) ? "CronPattern: a one-time date is not supported" : "Invalid ISO8601 passed to timezone parser.";
|
|
138
|
+
throw new Error(`schedule "${schedule}" is not a cron expression or "every <duration>": ${reason}`);
|
|
139
|
+
}
|
|
103
140
|
let cron;
|
|
104
141
|
try {
|
|
105
142
|
cron = new croner.Cron(text, timezone ? { timezone } : {});
|
|
@@ -108,6 +145,7 @@ function parseSchedule(schedule, timezone) {
|
|
|
108
145
|
}
|
|
109
146
|
parsed = { kind: "cron", source: text, ...timezone ? { timezone } : {} };
|
|
110
147
|
crons.set(parsed, cron);
|
|
148
|
+
if (neverFires(cron)) never.add(parsed);
|
|
111
149
|
}
|
|
112
150
|
cache.set(key, parsed);
|
|
113
151
|
return parsed;
|
|
@@ -134,6 +172,7 @@ function countFrom(from) {
|
|
|
134
172
|
function fireAfter(parsed, from) {
|
|
135
173
|
const cron = crons.get(parsed);
|
|
136
174
|
if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
|
|
175
|
+
if (never.has(parsed)) return null;
|
|
137
176
|
const start = countFrom(from);
|
|
138
177
|
if (start === null) return null;
|
|
139
178
|
let probe = start;
|
|
@@ -149,6 +188,7 @@ function fireAfter(parsed, from) {
|
|
|
149
188
|
function firesBetween(parsed, from, to, limit) {
|
|
150
189
|
const cron = crons.get(parsed);
|
|
151
190
|
if (!cron) throw new Error(`schedule "${parsed.source}" was not made by parseSchedule`);
|
|
191
|
+
if (never.has(parsed)) return [];
|
|
152
192
|
const out = [];
|
|
153
193
|
const start = countFrom(from);
|
|
154
194
|
if (start === null) return out;
|
|
@@ -283,16 +323,24 @@ function failureCount(state) {
|
|
|
283
323
|
function emptyState(job) {
|
|
284
324
|
return { job, open: {}, consecutiveFailures: 0, silencedUntil: null, lastAlertAt: null, pendingRecovery: [], undelivered: [] };
|
|
285
325
|
}
|
|
326
|
+
function isObject(value) {
|
|
327
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
328
|
+
}
|
|
286
329
|
function normalizeState(state, job) {
|
|
287
|
-
if (!state) return emptyState(job);
|
|
330
|
+
if (!isObject(state)) return emptyState(job);
|
|
288
331
|
const { sending, ...rest } = state;
|
|
332
|
+
const open = isObject(state.open) ? Object.fromEntries(Object.entries(state.open).filter(([, at]) => typeof at === "number")) : {};
|
|
333
|
+
const number = (value) => typeof value === "number" ? value : null;
|
|
334
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
289
335
|
return {
|
|
290
336
|
...emptyState(job),
|
|
291
337
|
...rest,
|
|
292
|
-
open
|
|
338
|
+
open,
|
|
293
339
|
consecutiveFailures: failureCount(state),
|
|
294
|
-
|
|
295
|
-
|
|
340
|
+
silencedUntil: number(state.silencedUntil),
|
|
341
|
+
lastAlertAt: number(state.lastAlertAt),
|
|
342
|
+
pendingRecovery: list(state.pendingRecovery).filter((c) => typeof c === "string"),
|
|
343
|
+
undelivered: list(state.undelivered).filter((alert) => isObject(alert)),
|
|
296
344
|
...Array.isArray(sending) && sending.length > 0 ? { sending: [...sending] } : {}
|
|
297
345
|
};
|
|
298
346
|
}
|
|
@@ -496,8 +544,12 @@ function applySilence(previous, evaluation, now) {
|
|
|
496
544
|
return { state: muteOpens(previous, evaluation.state), alerts: [] };
|
|
497
545
|
}
|
|
498
546
|
function staleAlert(alert, state) {
|
|
499
|
-
if (alert.type === "recovered")
|
|
500
|
-
|
|
547
|
+
if (alert.type === "recovered") {
|
|
548
|
+
const after2 = isObject(alert.details) ? alert.details.after : void 0;
|
|
549
|
+
if (!Array.isArray(after2)) return true;
|
|
550
|
+
return after2.some((condition) => typeof condition !== "string" || state.open[condition] !== void 0);
|
|
551
|
+
}
|
|
552
|
+
return typeof alert.at !== "number" || state.open[alert.type] !== alert.at;
|
|
501
553
|
}
|
|
502
554
|
function jobHealth(def, lastRun, state, now) {
|
|
503
555
|
const open = openConditions(state);
|
|
@@ -810,6 +862,15 @@ function checkExpectation(expect, output) {
|
|
|
810
862
|
}
|
|
811
863
|
return ok ? null : "Output did not pass the expect() check";
|
|
812
864
|
}
|
|
865
|
+
function readStoredJob(stored) {
|
|
866
|
+
const definition = stored.definition;
|
|
867
|
+
if (typeof definition !== "object" || definition === null || Array.isArray(definition)) {
|
|
868
|
+
return { job: { ...stored, definition: { name: stored.name } }, readable: false };
|
|
869
|
+
}
|
|
870
|
+
const read = { ...definition };
|
|
871
|
+
if ("tags" in read && !(Array.isArray(read.tags) && read.tags.every((tag) => typeof tag === "string"))) delete read.tags;
|
|
872
|
+
return { job: { ...stored, definition: read }, readable: true };
|
|
873
|
+
}
|
|
813
874
|
|
|
814
875
|
// src/routes/escape.ts
|
|
815
876
|
function escapeHtml(value) {
|
|
@@ -853,6 +914,21 @@ function manifest(base) {
|
|
|
853
914
|
});
|
|
854
915
|
}
|
|
855
916
|
var APP_JS = `"use strict";
|
|
917
|
+
(function () {
|
|
918
|
+
var root = document.documentElement;
|
|
919
|
+
try {
|
|
920
|
+
var stored = localStorage.getItem("cronwatch-theme");
|
|
921
|
+
if (stored === "light" || stored === "dark") root.setAttribute("data-theme", stored);
|
|
922
|
+
} catch (e) {}
|
|
923
|
+
document.addEventListener("keydown", function (event) {
|
|
924
|
+
if (!(event.metaKey || event.ctrlKey) || !event.shiftKey || event.altKey || event.code !== "KeyD") return;
|
|
925
|
+
event.preventDefault();
|
|
926
|
+
var shown = root.getAttribute("data-theme") || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
|
927
|
+
var next = shown === "dark" ? "light" : "dark";
|
|
928
|
+
root.setAttribute("data-theme", next);
|
|
929
|
+
try { localStorage.setItem("cronwatch-theme", next); } catch (e) {}
|
|
930
|
+
});
|
|
931
|
+
})();
|
|
856
932
|
(function () {
|
|
857
933
|
var script = document.currentScript;
|
|
858
934
|
if (!script || !("serviceWorker" in navigator)) return;
|
|
@@ -1229,7 +1305,8 @@ function weekRunsLimit(job, now) {
|
|
|
1229
1305
|
var h = escapeHtml;
|
|
1230
1306
|
var CSS = `
|
|
1231
1307
|
:root{color-scheme:light dark;--paper:#f4f4f5;--sheet:#fff;--sunk:#fafafa;--rule:#e4e4e7;--rule-2:#d4d4d8;--tick:#909098;--ink:#000;--body:#18181b;--muted:#71717a;--ok:#15803d;--warn:#a16207;--bad:#b91c1c;--serif:"Newsreader",ui-serif,Georgia,Cambria,"Times New Roman",serif;--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--who:200px}
|
|
1232
|
-
@media(prefers-color-scheme:dark){:root{--paper:#09090b;--sheet:#111113;--sunk:#18181b;--rule:#27272a;--rule-2:#3f3f46;--tick:#66666f;--ink:#fff;--body:#e4e4e7;--muted:#a1a1aa;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171}}
|
|
1308
|
+
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){--paper:#09090b;--sheet:#111113;--sunk:#18181b;--rule:#27272a;--rule-2:#3f3f46;--tick:#66666f;--ink:#fff;--body:#e4e4e7;--muted:#a1a1aa;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171}}
|
|
1309
|
+
:root[data-theme=light]{color-scheme:light}:root[data-theme=dark]{color-scheme:dark;--paper:#09090b;--sheet:#111113;--sunk:#18181b;--rule:#27272a;--rule-2:#3f3f46;--tick:#66666f;--ink:#fff;--body:#e4e4e7;--muted:#a1a1aa;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171}
|
|
1233
1310
|
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
|
|
1234
1311
|
body{margin:0;background:var(--paper);color:var(--ink);font:400 16px/1.55 var(--serif);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
|
|
1235
1312
|
a{color:inherit;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:.16em;text-decoration-color:var(--rule-2)}a:hover{text-decoration-color:currentColor}
|
|
@@ -1581,12 +1658,18 @@ function jobPage(job, runs, now, base, complete = true) {
|
|
|
1581
1658
|
return layout(`${job.name}: CronWatch`, body, base, { refresh: 60 });
|
|
1582
1659
|
}
|
|
1583
1660
|
function messagePage(title, message, base, signIn = false) {
|
|
1584
|
-
const form = signIn ? `<form class="signin" method="
|
|
1661
|
+
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>` : "";
|
|
1585
1662
|
return layout(title, `<header class="top">${brand(base)}</header><main class="message"><h1>${h(title)}</h1><p>${h(message)}</p>${form}</main>`, base);
|
|
1586
1663
|
}
|
|
1587
1664
|
|
|
1665
|
+
// src/version.ts
|
|
1666
|
+
var VERSION = "0.11.1";
|
|
1667
|
+
var API_VERSION = 1;
|
|
1668
|
+
|
|
1588
1669
|
// src/routes/index.ts
|
|
1589
1670
|
var COOKIE = "cronwatch_token";
|
|
1671
|
+
var LIBRARY = "@cronwatch/sdk";
|
|
1672
|
+
var LANGUAGE = "typescript";
|
|
1590
1673
|
async function cookieValue(token) {
|
|
1591
1674
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`cronwatch-cookie:${token}`));
|
|
1592
1675
|
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -1670,6 +1753,20 @@ function forwardedOrigin(request, url) {
|
|
|
1670
1753
|
return url.origin;
|
|
1671
1754
|
}
|
|
1672
1755
|
}
|
|
1756
|
+
function bearerToken(header) {
|
|
1757
|
+
const match = header === null ? null : /^Bearer\s+([\s\S]*)$/i.exec(header);
|
|
1758
|
+
return match ? match[1] : null;
|
|
1759
|
+
}
|
|
1760
|
+
function signInReturn(referer, publicOrigin, base) {
|
|
1761
|
+
if (referer === null || !referer.startsWith(publicOrigin + "/")) return `${base}/`;
|
|
1762
|
+
let url;
|
|
1763
|
+
try {
|
|
1764
|
+
url = new URL(referer);
|
|
1765
|
+
} catch {
|
|
1766
|
+
return `${base}/`;
|
|
1767
|
+
}
|
|
1768
|
+
return url.searchParams.has("token") ? `${base}/` : referer;
|
|
1769
|
+
}
|
|
1673
1770
|
function stripBase(pathname, base) {
|
|
1674
1771
|
let path = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
|
|
1675
1772
|
if (path === "") path = "/";
|
|
@@ -1712,8 +1809,12 @@ function runsLimit(value) {
|
|
|
1712
1809
|
return Number.isFinite(n) ? Math.min(MAX_RUNS, Math.max(1, n)) : DEFAULT_RUNS;
|
|
1713
1810
|
}
|
|
1714
1811
|
function createRoutes(cw, options = {}) {
|
|
1812
|
+
return cw.routes(options);
|
|
1813
|
+
}
|
|
1814
|
+
function buildRoutes(cw, options = {}) {
|
|
1715
1815
|
const optedOut = options.token === null;
|
|
1716
|
-
const
|
|
1816
|
+
const given = secretOption(options.token, "routes: token");
|
|
1817
|
+
const configured = optedOut ? null : given || readSecretEnv("CRONWATCH_TOKEN") || null;
|
|
1717
1818
|
const base = (options.basePath ?? "/cronwatch").replace(/\/+$/, "");
|
|
1718
1819
|
const developing = isDevelopment();
|
|
1719
1820
|
const fixedOrigin = configuredOrigin(options.origin);
|
|
@@ -1745,7 +1846,17 @@ function createRoutes(cw, options = {}) {
|
|
|
1745
1846
|
if (method !== "GET" && method !== "HEAD" && crossSite(request, publicOrigin)) {
|
|
1746
1847
|
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);
|
|
1747
1848
|
}
|
|
1748
|
-
const bearer = request.headers.get("authorization")
|
|
1849
|
+
const bearer = bearerToken(request.headers.get("authorization"));
|
|
1850
|
+
const signInCookie = async () => {
|
|
1851
|
+
const secure = publicOrigin.startsWith("https:") ? "; Secure" : "";
|
|
1852
|
+
return `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`;
|
|
1853
|
+
};
|
|
1854
|
+
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);
|
|
1855
|
+
if (token && method === "POST" && path === "/signin") {
|
|
1856
|
+
const sent = (await readBody(request)).token;
|
|
1857
|
+
if (sent === void 0 || !constantTimeEqual(sent, token)) return signInPage();
|
|
1858
|
+
return redirect(signInReturn(request.headers.get("referer"), publicOrigin, base), { "set-cookie": await signInCookie() });
|
|
1859
|
+
}
|
|
1749
1860
|
if (token) {
|
|
1750
1861
|
const query = wantsHtml && method === "GET" ? url.searchParams.get("token") : null;
|
|
1751
1862
|
const sent = readCookie(request, COOKIE);
|
|
@@ -1753,17 +1864,12 @@ function createRoutes(cw, options = {}) {
|
|
|
1753
1864
|
const cronSecretOk = isCheck && bearer !== null && cw.cronSecret !== null && constantTimeEqual(bearer, cw.cronSecret);
|
|
1754
1865
|
const tokenOk = bearer !== null ? constantTimeEqual(bearer, token) : query !== null ? constantTimeEqual(query, token) : sent !== null && constantTimeEqual(sent, await expectedCookie(token));
|
|
1755
1866
|
if (!cronSecretOk && !tokenOk) {
|
|
1756
|
-
if (
|
|
1757
|
-
|
|
1758
|
-
}
|
|
1759
|
-
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);
|
|
1867
|
+
if (wantsHtml) return signInPage();
|
|
1868
|
+
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);
|
|
1760
1869
|
}
|
|
1761
1870
|
if (query !== null) {
|
|
1762
1871
|
url.searchParams.delete("token");
|
|
1763
|
-
|
|
1764
|
-
return redirect(url.pathname + (url.search || ""), {
|
|
1765
|
-
"set-cookie": `${COOKIE}=${await expectedCookie(token)}; Path=${base || "/"}; HttpOnly; SameSite=Lax; Max-Age=${60 * 60 * 24 * 30}${secure}`
|
|
1766
|
-
});
|
|
1872
|
+
return redirect(url.pathname + (url.search || ""), { "set-cookie": await signInCookie() });
|
|
1767
1873
|
}
|
|
1768
1874
|
}
|
|
1769
1875
|
const redirectBack = () => {
|
|
@@ -1814,6 +1920,9 @@ function createRoutes(cw, options = {}) {
|
|
|
1814
1920
|
}
|
|
1815
1921
|
if (parts[0] === "api") {
|
|
1816
1922
|
const rest = parts.slice(1);
|
|
1923
|
+
if (method === "GET" && rest.length === 0) {
|
|
1924
|
+
return api({ ok: true, library: LIBRARY, language: LANGUAGE, version: VERSION, api: API_VERSION });
|
|
1925
|
+
}
|
|
1817
1926
|
if (method === "GET" && rest[0] === "jobs" && rest.length === 1) {
|
|
1818
1927
|
return api({ ok: true, jobs: await cw.jobs() });
|
|
1819
1928
|
}
|
|
@@ -1841,9 +1950,13 @@ function createRoutes(cw, options = {}) {
|
|
|
1841
1950
|
} catch (e) {
|
|
1842
1951
|
return api({ ok: false, error: e.message }, 400);
|
|
1843
1952
|
}
|
|
1844
|
-
|
|
1953
|
+
await cw.silence(name, duration);
|
|
1954
|
+
return api({ ok: true, job: await cw.jobSummary(name) });
|
|
1955
|
+
}
|
|
1956
|
+
if (rest[2] === "unsilence") {
|
|
1957
|
+
await cw.unsilence(name);
|
|
1958
|
+
return api({ ok: true, job: await cw.jobSummary(name) });
|
|
1845
1959
|
}
|
|
1846
|
-
if (rest[2] === "unsilence") return api({ ok: true, state: await cw.unsilence(name) });
|
|
1847
1960
|
}
|
|
1848
1961
|
if (rest[0] === "check" && rest.length === 1) {
|
|
1849
1962
|
if (method === "GET" && bearer === null) {
|
|
@@ -2051,7 +2164,7 @@ function validateDefinition(def) {
|
|
|
2051
2164
|
function sameState(a, b) {
|
|
2052
2165
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
2053
2166
|
}
|
|
2054
|
-
var
|
|
2167
|
+
var Cronwatch = class {
|
|
2055
2168
|
store;
|
|
2056
2169
|
alerts;
|
|
2057
2170
|
triage;
|
|
@@ -2064,7 +2177,7 @@ var CronWatch = class {
|
|
|
2064
2177
|
/** cronSecret was passed as null: handlers may run without a secret. */
|
|
2065
2178
|
secretOptOut;
|
|
2066
2179
|
redact;
|
|
2067
|
-
/** "check": queue alerts for another process's check instead of sending them. See
|
|
2180
|
+
/** "check": queue alerts for another process's check instead of sending them. See CronwatchOptions.deliver. */
|
|
2068
2181
|
deferDelivery;
|
|
2069
2182
|
defaults;
|
|
2070
2183
|
definitions = /* @__PURE__ */ new Map();
|
|
@@ -2083,12 +2196,15 @@ var CronWatch = class {
|
|
|
2083
2196
|
usingDefaultStore = false;
|
|
2084
2197
|
warnedNoSecret = false;
|
|
2085
2198
|
warnedDeferredStart = false;
|
|
2199
|
+
/** Stored jobs read with a definition that was not a JSON object (see readJob). */
|
|
2200
|
+
unreadable = /* @__PURE__ */ new WeakSet();
|
|
2086
2201
|
constructor(options = {}) {
|
|
2087
2202
|
this.store = options.store ?? (this.usingDefaultStore = true, memory());
|
|
2088
2203
|
this.alerts = options.alerts ?? [consoleChannel()];
|
|
2089
2204
|
this.triage = options.triage;
|
|
2090
2205
|
this.sources = options.sources ?? [];
|
|
2091
|
-
const
|
|
2206
|
+
const given = secretOption(options.cronSecret, "cronSecret");
|
|
2207
|
+
const secret = options.cronSecret === void 0 ? readSecretEnv("CRON_SECRET") : given;
|
|
2092
2208
|
this.cronSecret = secret ? secret : null;
|
|
2093
2209
|
this.secretOptOut = options.cronSecret === null;
|
|
2094
2210
|
this.retentionMs = parseDuration(options.retention ?? "30d", "retention");
|
|
@@ -2149,7 +2265,7 @@ var CronWatch = class {
|
|
|
2149
2265
|
return outcome.result;
|
|
2150
2266
|
},
|
|
2151
2267
|
handler(fn, options) {
|
|
2152
|
-
const own = options?.secret;
|
|
2268
|
+
const own = secretOption(options?.secret, "handler: secret");
|
|
2153
2269
|
const secret = own === null ? null : own ? own : self.cronSecret;
|
|
2154
2270
|
const optedOut = own === null || !own && self.secretOptOut;
|
|
2155
2271
|
return async (request) => {
|
|
@@ -2201,7 +2317,7 @@ var CronWatch = class {
|
|
|
2201
2317
|
if (!this.ready) {
|
|
2202
2318
|
this.ready = (async () => {
|
|
2203
2319
|
if (this.store.init) await this.store.init();
|
|
2204
|
-
if (this.usingDefaultStore &&
|
|
2320
|
+
if (this.usingDefaultStore && isProduction()) {
|
|
2205
2321
|
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.");
|
|
2206
2322
|
}
|
|
2207
2323
|
})().catch((error) => {
|
|
@@ -2251,13 +2367,23 @@ var CronWatch = class {
|
|
|
2251
2367
|
const jobs = await this.store.listJobs();
|
|
2252
2368
|
const listed = new Set(jobs.map((job) => job.name));
|
|
2253
2369
|
const missing = [...this.definitions.values()].filter((definition) => !listed.has(definition.name));
|
|
2254
|
-
if (missing.length === 0) return jobs;
|
|
2370
|
+
if (missing.length === 0) return jobs.map((job) => this.readJob(job));
|
|
2255
2371
|
for (const definition of missing) {
|
|
2256
2372
|
if (this.definitions.get(definition.name) !== definition) continue;
|
|
2257
2373
|
this.synced.delete(definition.name);
|
|
2258
2374
|
await this.sync(definition);
|
|
2259
2375
|
}
|
|
2260
|
-
return this.store.listJobs();
|
|
2376
|
+
return (await this.store.listJobs()).map((job) => this.readJob(job));
|
|
2377
|
+
}
|
|
2378
|
+
/** A stored job read leniently (readStoredJob); one whose definition is unreadable is remembered, so that evaluating it throws. */
|
|
2379
|
+
readJob(stored) {
|
|
2380
|
+
const { job, readable } = readStoredJob(stored);
|
|
2381
|
+
if (!readable) this.unreadable.add(job);
|
|
2382
|
+
return job;
|
|
2383
|
+
}
|
|
2384
|
+
/** Throws for a job whose stored definition was not a JSON object: reported, and shown as failing, while the others carry on. */
|
|
2385
|
+
evaluable(stored) {
|
|
2386
|
+
if (this.unreadable.has(stored)) throw new Error(`job "${stored.name}": its stored definition is not a JSON object`);
|
|
2261
2387
|
}
|
|
2262
2388
|
/**
|
|
2263
2389
|
* Runs `fn` after every earlier state update for the same job has settled,
|
|
@@ -2676,6 +2802,10 @@ ${id}`;
|
|
|
2676
2802
|
async recordRun(input, options = {}) {
|
|
2677
2803
|
const declared = this.definitions.get(input.job);
|
|
2678
2804
|
if (!declared) throw new Error(`recordRun: job "${input.job}" is not declared; call job() first`);
|
|
2805
|
+
if (typeof input.id !== "string" || input.id.length === 0 || input.id.length > MAX_RUN_ID) {
|
|
2806
|
+
const got = typeof input.id === "string" ? `${input.id.length} characters` : typeof input.id;
|
|
2807
|
+
throw new Error(`recordRun: run ids must be 1 to ${MAX_RUN_ID} characters (got ${got}; job "${input.job}")`);
|
|
2808
|
+
}
|
|
2679
2809
|
if (input.id.includes("\0")) throw new Error(`recordRun: run ids cannot contain a NUL character (job "${input.job}")`);
|
|
2680
2810
|
for (const [metric, value] of Object.entries(input.metrics ?? {})) {
|
|
2681
2811
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
@@ -2804,7 +2934,10 @@ ${id}`;
|
|
|
2804
2934
|
for (const listed of await this.store.runningRuns()) {
|
|
2805
2935
|
try {
|
|
2806
2936
|
const declared = this.definitions.get(listed.job);
|
|
2807
|
-
const
|
|
2937
|
+
const found = declared ? null : await this.store.getJob(listed.job);
|
|
2938
|
+
const stored = found ? this.readJob(found) : null;
|
|
2939
|
+
if (stored) this.evaluable(stored);
|
|
2940
|
+
const definition = declared ? toStored(declared) : stored?.definition;
|
|
2808
2941
|
if (!definition || !isStuck(definition, listed, now)) continue;
|
|
2809
2942
|
const run = await this.store.getRun(listed.id);
|
|
2810
2943
|
if (!run || run.status !== "running" || run.job !== listed.job) continue;
|
|
@@ -2823,6 +2956,7 @@ ${id}`;
|
|
|
2823
2956
|
const retries = { spentMs: 0 };
|
|
2824
2957
|
for (const stored of await this.storedJobs()) {
|
|
2825
2958
|
try {
|
|
2959
|
+
this.evaluable(stored);
|
|
2826
2960
|
const recent = await this.store.listRuns(stored.name, BASELINE_WINDOW);
|
|
2827
2961
|
let nextExpectedAt = null;
|
|
2828
2962
|
const { state, result: held } = await this.updateState(stored.name, (previous) => {
|
|
@@ -2858,6 +2992,7 @@ ${id}`;
|
|
|
2858
2992
|
let recent = [];
|
|
2859
2993
|
try {
|
|
2860
2994
|
recent = await this.store.listRuns(stored.name, Math.max(runs, BASELINE_WINDOW));
|
|
2995
|
+
this.evaluable(stored);
|
|
2861
2996
|
const state = await this.readState(stored.name);
|
|
2862
2997
|
const { nextExpectedAt } = onCheck(stored.definition, stored, recent[0] ?? null, state, now);
|
|
2863
2998
|
return { job: summarize(stored, recent, state, nextExpectedAt, now), runs: recent.slice(0, runs) };
|
|
@@ -2892,7 +3027,7 @@ ${id}`;
|
|
|
2892
3027
|
if (definition) await this.sync(definition, true);
|
|
2893
3028
|
const stored = await this.store.getJob(name);
|
|
2894
3029
|
if (!stored) return null;
|
|
2895
|
-
return (await this.snapshot(stored, this.now(), 0)).job;
|
|
3030
|
+
return (await this.snapshot(this.readJob(stored), this.now(), 0)).job;
|
|
2896
3031
|
}
|
|
2897
3032
|
/** A job's runs, newest first. `limit` is a whole number from 1 to 500. */
|
|
2898
3033
|
async runs(name, limit = 50) {
|
|
@@ -2939,21 +3074,27 @@ ${id}`;
|
|
|
2939
3074
|
this.synced.delete(name);
|
|
2940
3075
|
await this.store.deleteJob(name);
|
|
2941
3076
|
}
|
|
2942
|
-
/**
|
|
3077
|
+
/**
|
|
3078
|
+
* The dashboard and JSON API as fetch-style handlers: the one way to mount
|
|
3079
|
+
* them. A Next.js app exports them from app/cronwatch/[[...path]]/route.ts:
|
|
3080
|
+
*
|
|
3081
|
+
* export const { GET, POST, DELETE } = cw.routes();
|
|
3082
|
+
*/
|
|
2943
3083
|
routes(options) {
|
|
2944
|
-
return
|
|
3084
|
+
return buildRoutes(this, options);
|
|
2945
3085
|
}
|
|
2946
3086
|
/**
|
|
2947
3087
|
* Check on an interval, for long-running servers. Default every minute.
|
|
2948
3088
|
* Not for serverless functions or Cloudflare Workers, where nothing runs
|
|
2949
|
-
* between requests: call check() from a cron there instead.
|
|
3089
|
+
* between requests: call check() from a cron there instead. (A job's
|
|
3090
|
+
* start() opens a run; this starts the checks.)
|
|
2950
3091
|
*/
|
|
2951
|
-
|
|
3092
|
+
startChecking(every = "1m") {
|
|
2952
3093
|
if (this.timer) return;
|
|
2953
3094
|
const ms = Math.min(TIMER_MAX_MS, Math.max(5e3, parseDuration(every, "check interval")));
|
|
2954
3095
|
if (this.deferDelivery && !this.warnedDeferredStart) {
|
|
2955
3096
|
this.warnedDeferredStart = true;
|
|
2956
|
-
console.warn('[cronwatch]
|
|
3097
|
+
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.');
|
|
2957
3098
|
}
|
|
2958
3099
|
const tick = () => this.check().catch((e) => this.onError(e, "check"));
|
|
2959
3100
|
this.timer = setInterval(tick, ms);
|
|
@@ -2964,6 +3105,15 @@ ${id}`;
|
|
|
2964
3105
|
}, 1e3);
|
|
2965
3106
|
if (typeof this.firstTick === "object" && "unref" in this.firstTick) this.firstTick.unref();
|
|
2966
3107
|
}
|
|
3108
|
+
/**
|
|
3109
|
+
* startChecking(), under its former name.
|
|
3110
|
+
* @deprecated Renamed `startChecking`, since a job's `start()` opens a run.
|
|
3111
|
+
* This name still works through 1.x and goes in 2.0.
|
|
3112
|
+
*/
|
|
3113
|
+
start(every = "1m") {
|
|
3114
|
+
this.startChecking(every);
|
|
3115
|
+
}
|
|
3116
|
+
/** Stop the checks startChecking() began. */
|
|
2967
3117
|
stop() {
|
|
2968
3118
|
if (this.timer) clearInterval(this.timer);
|
|
2969
3119
|
if (this.firstTick) clearTimeout(this.firstTick);
|
|
@@ -3076,9 +3226,10 @@ ${id}`;
|
|
|
3076
3226
|
}
|
|
3077
3227
|
};
|
|
3078
3228
|
var RESERVED_RUN_ID_PREFIX = "pgcron:";
|
|
3229
|
+
var MAX_RUN_ID = 200;
|
|
3079
3230
|
function checkRunId(job, id, method) {
|
|
3080
|
-
if (typeof id !== "string" || id.length === 0 || id.length >
|
|
3081
|
-
throw new Error(`job "${job}": ${method}() needs a run id of 1 to
|
|
3231
|
+
if (typeof id !== "string" || id.length === 0 || id.length > MAX_RUN_ID) {
|
|
3232
|
+
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})`);
|
|
3082
3233
|
}
|
|
3083
3234
|
if (id.includes("\0")) throw new Error(`job "${job}": ${method}() cannot take a run id containing a NUL character`);
|
|
3084
3235
|
if (id.startsWith(RESERVED_RUN_ID_PREFIX)) {
|
|
@@ -3139,13 +3290,16 @@ function custom(name, send) {
|
|
|
3139
3290
|
await send(alert);
|
|
3140
3291
|
} };
|
|
3141
3292
|
}
|
|
3293
|
+
var CronWatch = Cronwatch;
|
|
3142
3294
|
|
|
3143
3295
|
// src/index.ts
|
|
3144
3296
|
function cronwatch(options = {}) {
|
|
3145
|
-
return new
|
|
3297
|
+
return new Cronwatch(options);
|
|
3146
3298
|
}
|
|
3147
3299
|
|
|
3148
3300
|
exports.CronWatch = CronWatch;
|
|
3301
|
+
exports.Cronwatch = Cronwatch;
|
|
3302
|
+
exports.VERSION = VERSION;
|
|
3149
3303
|
exports.composeAlert = composeAlert;
|
|
3150
3304
|
exports.consoleChannel = consoleChannel;
|
|
3151
3305
|
exports.createRoutes = createRoutes;
|