@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.
Files changed (86) hide show
  1. package/README.md +4 -4
  2. package/dist/anthropic.d.cts +1 -1
  3. package/dist/anthropic.d.ts +1 -1
  4. package/dist/bugsnag.d.cts +1 -1
  5. package/dist/bugsnag.d.ts +1 -1
  6. package/dist/{client-D35cfnNV.d.cts → client-CRMfhylq.d.cts} +129 -37
  7. package/dist/{client-CQm-8-mR.d.ts → client-D2Xk2600.d.ts} +129 -37
  8. package/dist/d1.cjs +29 -11
  9. package/dist/d1.cjs.map +1 -1
  10. package/dist/d1.d.cts +1 -1
  11. package/dist/d1.d.ts +1 -1
  12. package/dist/d1.js +29 -11
  13. package/dist/d1.js.map +1 -1
  14. package/dist/datadog.d.cts +1 -1
  15. package/dist/datadog.d.ts +1 -1
  16. package/dist/discord.cjs +25 -4
  17. package/dist/discord.cjs.map +1 -1
  18. package/dist/discord.d.cts +20 -4
  19. package/dist/discord.d.ts +20 -4
  20. package/dist/discord.js +22 -3
  21. package/dist/discord.js.map +1 -1
  22. package/dist/{email-BxCp9RQV.d.ts → email-BWuUiCkw.d.ts} +1 -1
  23. package/dist/{email-D1d5niMy.d.cts → email-Dcqe3k8n.d.cts} +1 -1
  24. package/dist/honeybadger.d.cts +1 -1
  25. package/dist/honeybadger.d.ts +1 -1
  26. package/dist/index.cjs +380 -123
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +15 -9
  29. package/dist/index.d.ts +15 -9
  30. package/dist/index.js +379 -124
  31. package/dist/index.js.map +1 -1
  32. package/dist/mailgun.d.cts +2 -2
  33. package/dist/mailgun.d.ts +2 -2
  34. package/dist/newrelic.d.cts +1 -1
  35. package/dist/newrelic.d.ts +1 -1
  36. package/dist/pg-cron.cjs +19 -8
  37. package/dist/pg-cron.cjs.map +1 -1
  38. package/dist/pg-cron.d.cts +15 -12
  39. package/dist/pg-cron.d.ts +15 -12
  40. package/dist/pg-cron.js +16 -5
  41. package/dist/pg-cron.js.map +1 -1
  42. package/dist/postgres.cjs +28 -10
  43. package/dist/postgres.cjs.map +1 -1
  44. package/dist/postgres.d.cts +1 -1
  45. package/dist/postgres.d.ts +1 -1
  46. package/dist/postgres.js +28 -10
  47. package/dist/postgres.js.map +1 -1
  48. package/dist/postmark.d.cts +2 -2
  49. package/dist/postmark.d.ts +2 -2
  50. package/dist/resend.d.cts +2 -2
  51. package/dist/resend.d.ts +2 -2
  52. package/dist/rollbar.d.cts +1 -1
  53. package/dist/rollbar.d.ts +1 -1
  54. package/dist/sendgrid.d.cts +2 -2
  55. package/dist/sendgrid.d.ts +2 -2
  56. package/dist/sentry.cjs +4 -1
  57. package/dist/sentry.cjs.map +1 -1
  58. package/dist/sentry.d.cts +5 -2
  59. package/dist/sentry.d.ts +5 -2
  60. package/dist/sentry.js +4 -1
  61. package/dist/sentry.js.map +1 -1
  62. package/dist/ses.d.cts +2 -2
  63. package/dist/ses.d.ts +2 -2
  64. package/dist/slack.d.cts +1 -1
  65. package/dist/slack.d.ts +1 -1
  66. package/dist/sqlite.cjs +29 -11
  67. package/dist/sqlite.cjs.map +1 -1
  68. package/dist/sqlite.d.cts +1 -1
  69. package/dist/sqlite.d.ts +1 -1
  70. package/dist/sqlite.js +29 -11
  71. package/dist/sqlite.js.map +1 -1
  72. package/dist/twilio.cjs +10 -4
  73. package/dist/twilio.cjs.map +1 -1
  74. package/dist/twilio.d.cts +12 -7
  75. package/dist/twilio.d.ts +12 -7
  76. package/dist/twilio.js +7 -1
  77. package/dist/twilio.js.map +1 -1
  78. package/dist/{types-Cz47Boks.d.cts → types-DLnlUie8.d.cts} +18 -2
  79. package/dist/{types-Cz47Boks.d.ts → types-DLnlUie8.d.ts} +18 -2
  80. package/dist/webhook.cjs +10 -5
  81. package/dist/webhook.cjs.map +1 -1
  82. package/dist/webhook.d.cts +18 -7
  83. package/dist/webhook.d.ts +18 -7
  84. package/dist/webhook.js +10 -6
  85. package/dist/webhook.js.map +1 -1
  86. package/package.json +1 -1
package/dist/twilio.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as Alert, a as AlertChannel } from './types-Cz47Boks.js';
1
+ import { A as Alert, a as AlertChannel } from './types-DLnlUie8.js';
2
2
 
3
3
  /**
4
4
  * Twilio SMS. API reference: https://www.twilio.com/docs/messaging/api/message-resource
@@ -26,10 +26,6 @@ interface TwilioOptions {
26
26
  segments?: number;
27
27
  link?: (alert: Alert) => string;
28
28
  }
29
- /** The most segments a message may use, which keeps it inside Twilio's 1600 character Body limit. */
30
- declare const MAX_SEGMENTS = 10;
31
- /** Twilio refuses a Body longer than this. */
32
- declare const MAX_BODY = 1600;
33
29
  /**
34
30
  * Texts alerts through Twilio, to every number at once. The alert counts as
35
31
  * delivered when any number took it; each number that refused it is
@@ -41,12 +37,21 @@ declare function twilio(options: TwilioOptions): AlertChannel;
41
37
  * extension character (two septets) or a surrogate pair (two UCS-2 units)
42
38
  * that would straddle a boundary starts the next segment, as phones pack them.
43
39
  */
44
- declare function smsSegments(text: string): number;
40
+ declare function smsSegments$1(text: string): number;
45
41
  /**
46
42
  * The title, then as many lines of the message (and the triage) as fit, then
47
43
  * the link. The link is kept whole; the text before it is cut to make room.
48
44
  * `segments` is clamped to 1 to 10.
49
45
  */
50
- declare function smsBody(alert: Alert, link: string | undefined, segments?: number): string;
46
+ declare function smsBody$1(alert: Alert, link: string | undefined, segments?: number): string;
47
+
48
+ /** @deprecated Internal to the Twilio channel; no longer exported from 1.0. */
49
+ declare const MAX_SEGMENTS = 10;
50
+ /** @deprecated Internal to the Twilio channel; no longer exported from 1.0. */
51
+ declare const MAX_BODY = 1600;
52
+ /** @deprecated Internal to the Twilio channel; no longer exported from 1.0. */
53
+ declare const smsSegments: typeof smsSegments$1;
54
+ /** @deprecated Internal to the Twilio channel; no longer exported from 1.0. */
55
+ declare const smsBody: typeof smsBody$1;
51
56
 
52
57
  export { MAX_BODY, MAX_SEGMENTS, type TwilioOptions, smsBody, smsSegments, twilio };
package/dist/twilio.js CHANGED
@@ -167,6 +167,12 @@ function segmentBudget(segments) {
167
167
  return Math.min(MAX_SEGMENTS, Math.max(1, n));
168
168
  }
169
169
 
170
- export { MAX_BODY, MAX_SEGMENTS, smsBody, smsSegments, twilio };
170
+ // src/entries/twilio.ts
171
+ var MAX_SEGMENTS2 = MAX_SEGMENTS;
172
+ var MAX_BODY2 = MAX_BODY;
173
+ var smsSegments2 = smsSegments;
174
+ var smsBody2 = smsBody;
175
+
176
+ export { MAX_BODY2 as MAX_BODY, MAX_SEGMENTS2 as MAX_SEGMENTS, smsBody2 as smsBody, smsSegments2 as smsSegments, twilio };
171
177
  //# sourceMappingURL=twilio.js.map
172
178
  //# sourceMappingURL=twilio.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/twilio.ts"],"names":[],"mappings":"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","file":"twilio.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","/**\n * Twilio SMS. API reference: https://www.twilio.com/docs/messaging/api/message-resource\n * POST https://api.twilio.com/2010-04-01/Accounts/<AccountSid>/Messages.json,\n * form encoded, with basic auth. One recipient per request.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { basicAuth, cut, post, trimmed } from \"./shared.js\";\n\nexport interface TwilioOptions {\n /** The account SID, \"AC...\". It is in the URL whichever credentials sign the request. */\n accountSid: string;\n /** The account's auth token. Or pass apiKeySid and apiKeySecret instead. */\n authToken?: string;\n /** An API key SID, \"SK...\", with apiKeySecret, in place of the auth token. */\n apiKeySid?: string;\n apiKeySecret?: string;\n /** A Twilio number in E.164 form, \"+15005550006\". Or pass messagingServiceSid. */\n from?: string;\n /** A messaging service SID, \"MG...\", in place of from. */\n messagingServiceSid?: string;\n /** One number in E.164 form, or several; each gets its own message. */\n to: string | string[];\n /** Also text when a job recovers. Defaults to false: a text is for what needs a person. */\n recovered?: boolean;\n /** How many SMS segments a message may use, 1 to 10. Defaults to 3. */\n segments?: number;\n link?: (alert: Alert) => string;\n}\n\n/** The most segments a message may use, which keeps it inside Twilio's 1600 character Body limit. */\nexport const MAX_SEGMENTS = 10;\n/** Twilio refuses a Body longer than this. */\nexport const MAX_BODY = 1600;\n\n/**\n * Texts alerts through Twilio, to every number at once. The alert counts as\n * delivered when any number took it; each number that refused it is\n * reported to the client's onError. It fails only when every number did.\n */\nexport function twilio(options: TwilioOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which the Authorization header would refuse or send.\n const accountSid = trimmed(options.accountSid);\n if (!accountSid) throw new Error(\"twilio() needs an accountSid\");\n const apiKeySid = trimmed(options.apiKeySid);\n const user = apiKeySid || accountSid;\n const password = apiKeySid ? trimmed(options.apiKeySecret) : trimmed(options.authToken);\n if (!password) throw new Error(\"twilio() needs an authToken, or an apiKeySid and apiKeySecret\");\n if (!options.from && !options.messagingServiceSid) throw new Error(\"twilio() needs a from number or a messagingServiceSid\");\n const to = (Array.isArray(options.to) ? options.to : [options.to]).filter((n) => typeof n === \"string\" && n.trim() !== \"\").map((n) => n.trim());\n if (to.length === 0) throw new Error(\"twilio() needs at least one to number\");\n const url = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(accountSid)}/Messages.json`;\n const authorization = basicAuth(user, password);\n const segments = segmentBudget(options.segments);\n return {\n name: \"twilio\",\n async send(alert, context) {\n if (alert.type === \"recovered\" && !options.recovered) return;\n const body = smsBody(alert, options.link?.(alert), segments);\n const results = await Promise.allSettled(to.map((number) => {\n const form = new URLSearchParams();\n form.append(\"To\", number);\n if (options.messagingServiceSid) form.append(\"MessagingServiceSid\", options.messagingServiceSid);\n else form.append(\"From\", options.from!);\n form.append(\"Body\", body);\n return post(\"Twilio\", url, { headers: { \"content-type\": \"application/x-www-form-urlencoded\", authorization }, body: form.toString() }, [password]);\n }));\n const failed = results.flatMap((r, i) => (r.status === \"rejected\" ? [{ number: to[i]!, error: r.reason as Error }] : []));\n if (failed.length === 0) return;\n if (failed.length === to.length) {\n const message = failed[0]!.error.message;\n throw new Error(to.length > 1 ? `${message} (${failed.length} of ${to.length} numbers failed)` : message);\n }\n // Delivered to someone: counted as sent, so a retry never texts the numbers that took it again.\n for (const { number, error } of failed) {\n const report = new Error(`${error.message} (to ${maskNumber(number)}; ${to.length - failed.length} of ${to.length} numbers took the alert)`);\n if (context) context.onError(report);\n else console.error(\"[cronwatch] alert channel twilio:\", report);\n }\n },\n };\n}\n\n/** A number with all but its last four digits hidden, for an error message. */\nfunction maskNumber(number: string): string {\n return number.length <= 4 ? number : `${\"*\".repeat(Math.min(number.length - 4, 8))}${number.slice(-4)}`;\n}\n\n// The GSM 03.38 alphabet: a message in it takes 153 characters a segment\n// (when split), anything else is UCS-2 at 67. The extension table costs two.\nconst GSM = \"@£$¥èéùìòÇ\\nØø\\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\\\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà\";\nconst GSM_EXTENDED = \"^{}\\\\[~]|€\\f\";\n\n/**\n * The segments `text` takes. A character is never split across two: an\n * extension character (two septets) or a surrogate pair (two UCS-2 units)\n * that would straddle a boundary starts the next segment, as phones pack them.\n */\nexport function smsSegments(text: string): number {\n const units: number[] = [];\n let gsm = true;\n for (const ch of text) {\n if (GSM.includes(ch)) units.push(1);\n else if (GSM_EXTENDED.includes(ch)) units.push(2);\n else {\n gsm = false;\n break;\n }\n }\n const [single, per, sizes] = gsm ? [160, 153, units] : [70, 67, Array.from(text, (ch) => ch.length)];\n const total = sizes.reduce((n, u) => n + u, 0);\n if (total <= single) return 1;\n let count = 1;\n let used = 0;\n for (const u of sizes) {\n if (used + u > per) {\n count += 1;\n used = 0;\n }\n used += u;\n }\n return count;\n}\n\n/** Fits `text` within `segments` SMS segments and Twilio's Body limit. */\nfunction fits(text: string, segments: number): boolean {\n return text.length <= MAX_BODY && smsSegments(text) <= segments;\n}\n\n/**\n * The title, then as many lines of the message (and the triage) as fit, then\n * the link. The link is kept whole; the text before it is cut to make room.\n * `segments` is clamped to 1 to 10.\n */\nexport function smsBody(alert: Alert, link: string | undefined, segments = 3): string {\n const budget = segmentBudget(segments);\n const tail = link ? `\\n${link}` : \"\";\n const lines = [alert.title, ...alert.message.split(\"\\n\").filter((l) => l.trim() !== \"\"), ...(alert.triage ? [`Triage: ${alert.triage}`] : [])];\n let text = \"\";\n for (const line of lines) {\n const next = text ? `${text}\\n${line}` : line;\n if (fits(next + tail, budget)) {\n text = next;\n continue;\n }\n // Part of this line, cut on a code point and marked.\n const chars = Array.from(line);\n let lo = 0;\n let hi = chars.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n const candidate = (text ? `${text}\\n` : \"\") + chars.slice(0, mid).join(\"\") + \"...\";\n if (fits(candidate + tail, budget)) lo = mid;\n else hi = mid - 1;\n }\n if (lo > 0) text = (text ? `${text}\\n` : \"\") + chars.slice(0, lo).join(\"\") + \"...\";\n break;\n }\n // Only a link too long for any budget gets here too long; Twilio would refuse it whole.\n return cut(text + tail, MAX_BODY);\n}\n\n/** A segment count clamped to 1 to MAX_SEGMENTS; 3 for anything not a number. */\nfunction segmentBudget(segments: number | undefined): number {\n const n = typeof segments === \"number\" && Number.isFinite(segments) ? Math.floor(segments) : 3;\n return Math.min(MAX_SEGMENTS, Math.max(1, n));\n}\n"]}
1
+ {"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/twilio.ts","../src/entries/twilio.ts"],"names":["MAX_SEGMENTS","MAX_BODY","smsSegments","smsBody"],"mappings":"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","file":"twilio.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","/**\n * Twilio SMS. API reference: https://www.twilio.com/docs/messaging/api/message-resource\n * POST https://api.twilio.com/2010-04-01/Accounts/<AccountSid>/Messages.json,\n * form encoded, with basic auth. One recipient per request.\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { basicAuth, cut, post, trimmed } from \"./shared.js\";\n\nexport interface TwilioOptions {\n /** The account SID, \"AC...\". It is in the URL whichever credentials sign the request. */\n accountSid: string;\n /** The account's auth token. Or pass apiKeySid and apiKeySecret instead. */\n authToken?: string;\n /** An API key SID, \"SK...\", with apiKeySecret, in place of the auth token. */\n apiKeySid?: string;\n apiKeySecret?: string;\n /** A Twilio number in E.164 form, \"+15005550006\". Or pass messagingServiceSid. */\n from?: string;\n /** A messaging service SID, \"MG...\", in place of from. */\n messagingServiceSid?: string;\n /** One number in E.164 form, or several; each gets its own message. */\n to: string | string[];\n /** Also text when a job recovers. Defaults to false: a text is for what needs a person. */\n recovered?: boolean;\n /** How many SMS segments a message may use, 1 to 10. Defaults to 3. */\n segments?: number;\n link?: (alert: Alert) => string;\n}\n\n/** The most segments a message may use, which keeps it inside Twilio's 1600 character Body limit. */\nexport const MAX_SEGMENTS = 10;\n/** Twilio refuses a Body longer than this. */\nexport const MAX_BODY = 1600;\n\n/**\n * Texts alerts through Twilio, to every number at once. The alert counts as\n * delivered when any number took it; each number that refused it is\n * reported to the client's onError. It fails only when every number did.\n */\nexport function twilio(options: TwilioOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which the Authorization header would refuse or send.\n const accountSid = trimmed(options.accountSid);\n if (!accountSid) throw new Error(\"twilio() needs an accountSid\");\n const apiKeySid = trimmed(options.apiKeySid);\n const user = apiKeySid || accountSid;\n const password = apiKeySid ? trimmed(options.apiKeySecret) : trimmed(options.authToken);\n if (!password) throw new Error(\"twilio() needs an authToken, or an apiKeySid and apiKeySecret\");\n if (!options.from && !options.messagingServiceSid) throw new Error(\"twilio() needs a from number or a messagingServiceSid\");\n const to = (Array.isArray(options.to) ? options.to : [options.to]).filter((n) => typeof n === \"string\" && n.trim() !== \"\").map((n) => n.trim());\n if (to.length === 0) throw new Error(\"twilio() needs at least one to number\");\n const url = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(accountSid)}/Messages.json`;\n const authorization = basicAuth(user, password);\n const segments = segmentBudget(options.segments);\n return {\n name: \"twilio\",\n async send(alert, context) {\n if (alert.type === \"recovered\" && !options.recovered) return;\n const body = smsBody(alert, options.link?.(alert), segments);\n const results = await Promise.allSettled(to.map((number) => {\n const form = new URLSearchParams();\n form.append(\"To\", number);\n if (options.messagingServiceSid) form.append(\"MessagingServiceSid\", options.messagingServiceSid);\n else form.append(\"From\", options.from!);\n form.append(\"Body\", body);\n return post(\"Twilio\", url, { headers: { \"content-type\": \"application/x-www-form-urlencoded\", authorization }, body: form.toString() }, [password]);\n }));\n const failed = results.flatMap((r, i) => (r.status === \"rejected\" ? [{ number: to[i]!, error: r.reason as Error }] : []));\n if (failed.length === 0) return;\n if (failed.length === to.length) {\n const message = failed[0]!.error.message;\n throw new Error(to.length > 1 ? `${message} (${failed.length} of ${to.length} numbers failed)` : message);\n }\n // Delivered to someone: counted as sent, so a retry never texts the numbers that took it again.\n for (const { number, error } of failed) {\n const report = new Error(`${error.message} (to ${maskNumber(number)}; ${to.length - failed.length} of ${to.length} numbers took the alert)`);\n if (context) context.onError(report);\n else console.error(\"[cronwatch] alert channel twilio:\", report);\n }\n },\n };\n}\n\n/** A number with all but its last four digits hidden, for an error message. */\nfunction maskNumber(number: string): string {\n return number.length <= 4 ? number : `${\"*\".repeat(Math.min(number.length - 4, 8))}${number.slice(-4)}`;\n}\n\n// The GSM 03.38 alphabet: a message in it takes 153 characters a segment\n// (when split), anything else is UCS-2 at 67. The extension table costs two.\nconst GSM = \"@£$¥èéùìòÇ\\nØø\\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\\\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà\";\nconst GSM_EXTENDED = \"^{}\\\\[~]|€\\f\";\n\n/**\n * The segments `text` takes. A character is never split across two: an\n * extension character (two septets) or a surrogate pair (two UCS-2 units)\n * that would straddle a boundary starts the next segment, as phones pack them.\n */\nexport function smsSegments(text: string): number {\n const units: number[] = [];\n let gsm = true;\n for (const ch of text) {\n if (GSM.includes(ch)) units.push(1);\n else if (GSM_EXTENDED.includes(ch)) units.push(2);\n else {\n gsm = false;\n break;\n }\n }\n const [single, per, sizes] = gsm ? [160, 153, units] : [70, 67, Array.from(text, (ch) => ch.length)];\n const total = sizes.reduce((n, u) => n + u, 0);\n if (total <= single) return 1;\n let count = 1;\n let used = 0;\n for (const u of sizes) {\n if (used + u > per) {\n count += 1;\n used = 0;\n }\n used += u;\n }\n return count;\n}\n\n/** Fits `text` within `segments` SMS segments and Twilio's Body limit. */\nfunction fits(text: string, segments: number): boolean {\n return text.length <= MAX_BODY && smsSegments(text) <= segments;\n}\n\n/**\n * The title, then as many lines of the message (and the triage) as fit, then\n * the link. The link is kept whole; the text before it is cut to make room.\n * `segments` is clamped to 1 to 10.\n */\nexport function smsBody(alert: Alert, link: string | undefined, segments = 3): string {\n const budget = segmentBudget(segments);\n const tail = link ? `\\n${link}` : \"\";\n const lines = [alert.title, ...alert.message.split(\"\\n\").filter((l) => l.trim() !== \"\"), ...(alert.triage ? [`Triage: ${alert.triage}`] : [])];\n let text = \"\";\n for (const line of lines) {\n const next = text ? `${text}\\n${line}` : line;\n if (fits(next + tail, budget)) {\n text = next;\n continue;\n }\n // Part of this line, cut on a code point and marked.\n const chars = Array.from(line);\n let lo = 0;\n let hi = chars.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n const candidate = (text ? `${text}\\n` : \"\") + chars.slice(0, mid).join(\"\") + \"...\";\n if (fits(candidate + tail, budget)) lo = mid;\n else hi = mid - 1;\n }\n if (lo > 0) text = (text ? `${text}\\n` : \"\") + chars.slice(0, lo).join(\"\") + \"...\";\n break;\n }\n // Only a link too long for any budget gets here too long; Twilio would refuse it whole.\n return cut(text + tail, MAX_BODY);\n}\n\n/** A segment count clamped to 1 to MAX_SEGMENTS; 3 for anything not a number. */\nfunction segmentBudget(segments: number | undefined): number {\n const n = typeof segments === \"number\" && Number.isFinite(segments) ? Math.floor(segments) : 3;\n return Math.min(MAX_SEGMENTS, Math.max(1, n));\n}\n","// @cronwatch/sdk/twilio: 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/twilio.js\";\n\nexport { twilio } from \"../alerts/twilio.js\";\nexport type { TwilioOptions } from \"../alerts/twilio.js\";\n\n/** @deprecated Internal to the Twilio channel; no longer exported from 1.0. */\nexport const MAX_SEGMENTS = internal.MAX_SEGMENTS;\n/** @deprecated Internal to the Twilio channel; no longer exported from 1.0. */\nexport const MAX_BODY = internal.MAX_BODY;\n/** @deprecated Internal to the Twilio channel; no longer exported from 1.0. */\nexport const smsSegments = internal.smsSegments;\n/** @deprecated Internal to the Twilio channel; no longer exported from 1.0. */\nexport const smsBody = internal.smsBody;\n"]}
@@ -77,7 +77,10 @@ interface Run {
77
77
  /** Lines written with job.log(), or the string the job returned. Capped at 16 KB. */
78
78
  output: string | null;
79
79
  metrics: Record<string, number>;
80
- /** What started the run: "handler", "run" or a value you pass. */
80
+ /**
81
+ * What started the run: "run", "start", "handler", an integration's name
82
+ * ("pg_cron" for the pg_cron source) or a value you pass.
83
+ */
81
84
  trigger: string;
82
85
  }
83
86
  type Condition = "missed" | "failed" | "stuck" | "slow" | "over_budget";
@@ -97,6 +100,14 @@ interface JobState {
97
100
  pendingRecovery?: Condition[];
98
101
  /** Alerts that no channel accepted. Each check retries them once. */
99
102
  undelivered?: Alert[];
103
+ /**
104
+ * The outbox: alerts written with the state that opened their condition,
105
+ * while the process that wrote them sends them. Each leaves once that
106
+ * process records how the send went; one still here after `until` (the
107
+ * process stopped part way) goes to `undelivered` at the next check.
108
+ * Absent when empty, and in state written before this field existed.
109
+ */
110
+ sending?: SendingAlert[];
100
111
  /**
101
112
  * Goes up by one on every write, so a store can refuse a write made from a
102
113
  * stale read (see Store.compareAndSetState). Absent counts as 0.
@@ -177,6 +188,11 @@ type Alert = {
177
188
  details: AlertDetails[K];
178
189
  };
179
190
  }[AlertType];
191
+ /** An alert in JobState.sending: `until` (epoch milliseconds) is when its sender's lease runs out. */
192
+ interface SendingAlert {
193
+ until: number;
194
+ alert: Alert;
195
+ }
180
196
  /** What the client hands a channel with each alert. */
181
197
  interface ChannelContext {
182
198
  /**
@@ -269,4 +285,4 @@ interface TriageContext {
269
285
  }
270
286
  type TriageFn = (context: TriageContext) => Promise<string | null>;
271
287
 
272
- export type { Alert as A, BudgetBreach as B, ChannelContext as C, Duration as D, ExpectRule as E, JobDefinition as J, Run as R, Store as S, TriageContext as T, AlertChannel as a, AlertDraft as b, StoredJobDefinition as c, AlertDetails as d, AlertType as e, CheckResult as f, Condition as g, JobHealth as h, JobOptions as i, JobState as j, JobSummary as k, RunStatus as l, StoredJob as m, TriageFn as n };
288
+ export type { Alert as A, BudgetBreach as B, ChannelContext as C, Duration as D, ExpectRule as E, JobOptions as J, Run as R, Store as S, TriageContext as T, AlertChannel as a, AlertDraft as b, StoredJobDefinition as c, AlertDetails as d, AlertType as e, CheckResult as f, Condition as g, JobDefinition as h, JobHealth as i, JobState as j, JobSummary as k, RunStatus as l, SendingAlert as m, StoredJob as n, TriageFn as o };
@@ -77,7 +77,10 @@ interface Run {
77
77
  /** Lines written with job.log(), or the string the job returned. Capped at 16 KB. */
78
78
  output: string | null;
79
79
  metrics: Record<string, number>;
80
- /** What started the run: "handler", "run" or a value you pass. */
80
+ /**
81
+ * What started the run: "run", "start", "handler", an integration's name
82
+ * ("pg_cron" for the pg_cron source) or a value you pass.
83
+ */
81
84
  trigger: string;
82
85
  }
83
86
  type Condition = "missed" | "failed" | "stuck" | "slow" | "over_budget";
@@ -97,6 +100,14 @@ interface JobState {
97
100
  pendingRecovery?: Condition[];
98
101
  /** Alerts that no channel accepted. Each check retries them once. */
99
102
  undelivered?: Alert[];
103
+ /**
104
+ * The outbox: alerts written with the state that opened their condition,
105
+ * while the process that wrote them sends them. Each leaves once that
106
+ * process records how the send went; one still here after `until` (the
107
+ * process stopped part way) goes to `undelivered` at the next check.
108
+ * Absent when empty, and in state written before this field existed.
109
+ */
110
+ sending?: SendingAlert[];
100
111
  /**
101
112
  * Goes up by one on every write, so a store can refuse a write made from a
102
113
  * stale read (see Store.compareAndSetState). Absent counts as 0.
@@ -177,6 +188,11 @@ type Alert = {
177
188
  details: AlertDetails[K];
178
189
  };
179
190
  }[AlertType];
191
+ /** An alert in JobState.sending: `until` (epoch milliseconds) is when its sender's lease runs out. */
192
+ interface SendingAlert {
193
+ until: number;
194
+ alert: Alert;
195
+ }
180
196
  /** What the client hands a channel with each alert. */
181
197
  interface ChannelContext {
182
198
  /**
@@ -269,4 +285,4 @@ interface TriageContext {
269
285
  }
270
286
  type TriageFn = (context: TriageContext) => Promise<string | null>;
271
287
 
272
- export type { Alert as A, BudgetBreach as B, ChannelContext as C, Duration as D, ExpectRule as E, JobDefinition as J, Run as R, Store as S, TriageContext as T, AlertChannel as a, AlertDraft as b, StoredJobDefinition as c, AlertDetails as d, AlertType as e, CheckResult as f, Condition as g, JobHealth as h, JobOptions as i, JobState as j, JobSummary as k, RunStatus as l, StoredJob as m, TriageFn as n };
288
+ export type { Alert as A, BudgetBreach as B, ChannelContext as C, Duration as D, ExpectRule as E, JobOptions as J, Run as R, Store as S, TriageContext as T, AlertChannel as a, AlertDraft as b, StoredJobDefinition as c, AlertDetails as d, AlertType as e, CheckResult as f, Condition as g, JobDefinition as h, JobHealth as i, JobState as j, JobSummary as k, RunStatus as l, SendingAlert as m, StoredJob as n, TriageFn as o };
package/dist/webhook.cjs CHANGED
@@ -14,6 +14,7 @@ function postable(url) {
14
14
 
15
15
  // src/alerts/webhook.ts
16
16
  var TIMEOUT_MS = 1e4;
17
+ var WEBHOOK_SCHEMA = 1;
17
18
  function origin(url) {
18
19
  try {
19
20
  return new URL(url).origin;
@@ -26,25 +27,29 @@ function webhook(options) {
26
27
  return {
27
28
  name: "webhook",
28
29
  async send(alert) {
29
- const body = JSON.stringify(alert);
30
+ const body = JSON.stringify({ schema: WEBHOOK_SCHEMA, ...alert });
30
31
  const headers = { "content-type": "application/json", "user-agent": "cronwatch" };
31
32
  for (const [name, value] of Object.entries(options.headers ?? {})) headers[name] = typeof value === "string" ? value.trim() : value;
32
33
  if (options.secret) {
33
- headers["x-cronwatch-signature"] = `sha256=${await hmacSha256Hex(options.secret, body)}`;
34
+ headers["x-cronwatch-signature"] = `sha256=${await signature(options.secret, body)}`;
34
35
  }
35
36
  const response = await fetch(postable(options.url), { method: "POST", headers, body, redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS) });
36
37
  if (!response.ok) throw new Error(`Webhook ${origin(options.url)} answered ${response.status}`);
37
38
  }
38
39
  };
39
40
  }
40
- async function hmacSha256Hex(secret, body) {
41
+ async function signature(secret, body) {
41
42
  const encoder = new TextEncoder();
42
43
  const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
43
- const signature = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(body)));
44
- return Array.from(signature, (b) => b.toString(16).padStart(2, "0")).join("");
44
+ const signature2 = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(body)));
45
+ return Array.from(signature2, (b) => b.toString(16).padStart(2, "0")).join("");
45
46
  }
46
47
 
48
+ // src/entries/webhook.ts
49
+ var hmacSha256Hex = signature;
50
+
47
51
  exports.hmacSha256Hex = hmacSha256Hex;
52
+ exports.signature = signature;
48
53
  exports.webhook = webhook;
49
54
  //# sourceMappingURL=webhook.cjs.map
50
55
  //# sourceMappingURL=webhook.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/webhook.ts"],"names":[],"mappings":";;;AAiCO,SAAS,SAAS,GAAA,EAAqB;AAC5C,EAAA,IAAI,QAAA;AACJ,EAAA,IAAI;AACF,IAAA,QAAA,GAAW,IAAI,GAAA,CAAI,GAAG,CAAA,CAAE,QAAA;AAAA,EAC1B,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,MAAM,yDAAyD,CAAA;AAAA,EAC3E;AACA,EAAA,IAAI,QAAA,KAAa,WAAW,QAAA,KAAa,QAAA,QAAgB,IAAI,KAAA,CAAM,CAAA,+CAAA,EAAkD,QAAQ,CAAA,CAAE,CAAA;AAC/H,EAAA,OAAO,GAAA;AACT;;;AC3BA,IAAM,UAAA,GAAa,GAAA;AAEnB,SAAS,OAAO,GAAA,EAAqB;AACnC,EAAA,IAAI;AACF,IAAA,OAAO,IAAI,GAAA,CAAI,GAAG,CAAA,CAAE,MAAA;AAAA,EACtB,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,eAAA;AAAA,EACT;AACF;AAOO,SAAS,QAAQ,OAAA,EAAuC;AAC7D,EAAA,IAAI,CAAC,OAAA,CAAQ,GAAA,EAAK,MAAM,IAAI,MAAM,uBAAuB,CAAA;AACzD,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,SAAA;AAAA,IACN,MAAM,KAAK,KAAA,EAAO;AAChB,MAAA,MAAM,IAAA,GAAO,IAAA,CAAK,SAAA,CAAU,KAAK,CAAA;AACjC,MAAA,MAAM,OAAA,GAAkC,EAAE,cAAA,EAAgB,kBAAA,EAAoB,cAAc,WAAA,EAAY;AAExG,MAAA,KAAA,MAAW,CAAC,IAAA,EAAM,KAAK,KAAK,MAAA,CAAO,OAAA,CAAQ,QAAQ,OAAA,IAAW,EAAE,CAAA,EAAG,OAAA,CAAQ,IAAI,CAAA,GAAI,OAAO,UAAU,QAAA,GAAW,KAAA,CAAM,MAAK,GAAI,KAAA;AAC9H,MAAA,IAAI,QAAQ,MAAA,EAAQ;AAClB,QAAA,OAAA,CAAQ,uBAAuB,IAAI,CAAA,OAAA,EAAU,MAAM,cAAc,OAAA,CAAQ,MAAA,EAAQ,IAAI,CAAC,CAAA,CAAA;AAAA,MACxF;AAEA,MAAA,MAAM,WAAW,MAAM,KAAA,CAAM,SAAS,OAAA,CAAQ,GAAG,GAAG,EAAE,MAAA,EAAQ,QAAQ,OAAA,EAAS,IAAA,EAAM,UAAU,OAAA,EAAS,MAAA,EAAQ,YAAY,OAAA,CAAQ,UAAU,GAAG,CAAA;AAEjJ,MAAA,IAAI,CAAC,QAAA,CAAS,EAAA,EAAI,MAAM,IAAI,KAAA,CAAM,CAAA,QAAA,EAAW,MAAA,CAAO,OAAA,CAAQ,GAAG,CAAC,CAAA,UAAA,EAAa,QAAA,CAAS,MAAM,CAAA,CAAE,CAAA;AAAA,IAChG;AAAA,GACF;AACF;AAGA,eAAsB,aAAA,CAAc,QAAgB,IAAA,EAA+B;AACjF,EAAA,MAAM,OAAA,GAAU,IAAI,WAAA,EAAY;AAChC,EAAA,MAAM,MAAM,MAAM,MAAA,CAAO,OAAO,SAAA,CAAU,KAAA,EAAO,QAAQ,MAAA,CAAO,MAAM,GAAG,EAAE,IAAA,EAAM,QAAQ,IAAA,EAAM,SAAA,IAAa,KAAA,EAAO,CAAC,MAAM,CAAC,CAAA;AAC3H,EAAA,MAAM,SAAA,GAAY,IAAI,UAAA,CAAW,MAAM,MAAA,CAAO,MAAA,CAAO,IAAA,CAAK,MAAA,EAAQ,GAAA,EAAK,OAAA,CAAQ,MAAA,CAAO,IAAI,CAAC,CAAC,CAAA;AAC5F,EAAA,OAAO,KAAA,CAAM,IAAA,CAAK,SAAA,EAAW,CAAC,MAAM,CAAA,CAAE,QAAA,CAAS,EAAE,CAAA,CAAE,SAAS,CAAA,EAAG,GAAG,CAAC,CAAA,CAAE,KAAK,EAAE,CAAA;AAC9E","file":"webhook.cjs","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 { AlertChannel } from \"../types.js\";\nimport { postable } from \"./shared.js\";\n\nexport interface WebhookOptions {\n url: string;\n /** Extra request headers, for an Authorization header say. Values are trimmed. */\n headers?: Record<string, string>;\n /**\n * When set, each request carries `X-CronWatch-Signature: sha256=<hex>`,\n * the HMAC-SHA256 of the raw body with this secret, so the receiver can\n * verify it.\n */\n secret?: string;\n}\n\nconst TIMEOUT_MS = 10_000;\n\nfunction origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * POSTs the alert as JSON to any URL. The body is the Alert object:\n * { type, job, title, message, run, details, triage, at, definition }.\n * A redirect is an error: point the url at where the receiver really is.\n */\nexport function webhook(options: WebhookOptions): AlertChannel {\n if (!options.url) throw new Error(\"webhook() needs a url\");\n return {\n name: \"webhook\",\n async send(alert) {\n const body = JSON.stringify(alert);\n const headers: Record<string, string> = { \"content-type\": \"application/json\", \"user-agent\": \"cronwatch\" };\n // A pasted Authorization value often carries a stray space or newline, which fetch would refuse.\n for (const [name, value] of Object.entries(options.headers ?? {})) headers[name] = typeof value === \"string\" ? value.trim() : value;\n if (options.secret) {\n headers[\"x-cronwatch-signature\"] = `sha256=${await hmacSha256Hex(options.secret, body)}`;\n }\n // A redirect is refused, not followed: the headers (and the signature) would go with it.\n const response = await fetch(postable(options.url), { method: \"POST\", headers, body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n // Only the origin: a webhook URL's path or query often is the credential.\n if (!response.ok) throw new Error(`Webhook ${origin(options.url)} answered ${response.status}`);\n },\n };\n}\n\n/** HMAC-SHA256 as lowercase hex, with Web Crypto so it runs on Workers and Deno too. */\nexport async function hmacSha256Hex(secret: string, body: string): Promise<string> {\n const encoder = new TextEncoder();\n const key = await crypto.subtle.importKey(\"raw\", encoder.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n const signature = new Uint8Array(await crypto.subtle.sign(\"HMAC\", key, encoder.encode(body)));\n return Array.from(signature, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n"]}
1
+ {"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/webhook.ts","../src/entries/webhook.ts"],"names":["signature"],"mappings":"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","file":"webhook.cjs","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 { AlertChannel } from \"../types.js\";\nimport { postable } from \"./shared.js\";\n\nexport interface WebhookOptions {\n url: string;\n /** Extra request headers, for an Authorization header say. Values are trimmed. */\n headers?: Record<string, string>;\n /**\n * When set, each request carries `X-CronWatch-Signature: sha256=<hex>`,\n * the HMAC-SHA256 of the raw body with this secret, so the receiver can\n * verify it.\n */\n secret?: string;\n}\n\nconst TIMEOUT_MS = 10_000;\n\n/**\n * The payload's version, sent as its first field. It goes up only if a\n * major release changes the payload in a way that is not additive; see\n * https://cronwatch.dev/schemas/webhook/1.json.\n */\nexport const WEBHOOK_SCHEMA = 1;\n\nfunction origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * POSTs the alert as JSON to any URL. The body is the Alert object after a\n * version: { schema: 1, type, job, title, message, run, details, triage, at,\n * definition }. A redirect is an error: point the url at where the receiver\n * really is.\n */\nexport function webhook(options: WebhookOptions): AlertChannel {\n if (!options.url) throw new Error(\"webhook() needs a url\");\n return {\n name: \"webhook\",\n async send(alert) {\n const body = JSON.stringify({ schema: WEBHOOK_SCHEMA, ...alert });\n const headers: Record<string, string> = { \"content-type\": \"application/json\", \"user-agent\": \"cronwatch\" };\n // A pasted Authorization value often carries a stray space or newline, which fetch would refuse.\n for (const [name, value] of Object.entries(options.headers ?? {})) headers[name] = typeof value === \"string\" ? value.trim() : value;\n if (options.secret) {\n headers[\"x-cronwatch-signature\"] = `sha256=${await signature(options.secret, body)}`;\n }\n // A redirect is refused, not followed: the headers (and the signature) would go with it.\n const response = await fetch(postable(options.url), { method: \"POST\", headers, body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n // Only the origin: a webhook URL's path or query often is the credential.\n if (!response.ok) throw new Error(`Webhook ${origin(options.url)} answered ${response.status}`);\n },\n };\n}\n\n/**\n * The webhook's signature of a body: the HMAC-SHA256 of the body with the\n * secret, as lowercase hex. A request carries it as\n * `X-CronWatch-Signature: sha256=<signature>`, so a receiver checks one with\n * the raw body it got (before any JSON parsing) and a constant-time compare.\n * Web Crypto, so it runs on Workers and Deno too. Every port has it, named\n * the same.\n */\nexport async function signature(secret: string, body: string): Promise<string> {\n const encoder = new TextEncoder();\n const key = await crypto.subtle.importKey(\"raw\", encoder.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n const signature = new Uint8Array(await crypto.subtle.sign(\"HMAC\", key, encoder.encode(body)));\n return Array.from(signature, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n","// @cronwatch/sdk/webhook: the channel, and signature(), the one helper for\n// a receiver, named the same in every port.\nimport * as internal from \"../alerts/webhook.js\";\n\nexport { signature, webhook } from \"../alerts/webhook.js\";\nexport type { WebhookOptions } from \"../alerts/webhook.js\";\n\n/** @deprecated Renamed `signature`, the name every port uses; this name is no longer exported from 1.0. */\nexport const hmacSha256Hex = internal.signature;\n"]}
@@ -1,4 +1,4 @@
1
- import { a as AlertChannel } from './types-Cz47Boks.cjs';
1
+ import { a as AlertChannel } from './types-DLnlUie8.cjs';
2
2
 
3
3
  interface WebhookOptions {
4
4
  url: string;
@@ -12,12 +12,23 @@ interface WebhookOptions {
12
12
  secret?: string;
13
13
  }
14
14
  /**
15
- * POSTs the alert as JSON to any URL. The body is the Alert object:
16
- * { type, job, title, message, run, details, triage, at, definition }.
17
- * A redirect is an error: point the url at where the receiver really is.
15
+ * POSTs the alert as JSON to any URL. The body is the Alert object after a
16
+ * version: { schema: 1, type, job, title, message, run, details, triage, at,
17
+ * definition }. A redirect is an error: point the url at where the receiver
18
+ * really is.
18
19
  */
19
20
  declare function webhook(options: WebhookOptions): AlertChannel;
20
- /** HMAC-SHA256 as lowercase hex, with Web Crypto so it runs on Workers and Deno too. */
21
- declare function hmacSha256Hex(secret: string, body: string): Promise<string>;
21
+ /**
22
+ * The webhook's signature of a body: the HMAC-SHA256 of the body with the
23
+ * secret, as lowercase hex. A request carries it as
24
+ * `X-CronWatch-Signature: sha256=<signature>`, so a receiver checks one with
25
+ * the raw body it got (before any JSON parsing) and a constant-time compare.
26
+ * Web Crypto, so it runs on Workers and Deno too. Every port has it, named
27
+ * the same.
28
+ */
29
+ declare function signature(secret: string, body: string): Promise<string>;
30
+
31
+ /** @deprecated Renamed `signature`, the name every port uses; this name is no longer exported from 1.0. */
32
+ declare const hmacSha256Hex: typeof signature;
22
33
 
23
- export { type WebhookOptions, hmacSha256Hex, webhook };
34
+ export { type WebhookOptions, hmacSha256Hex, signature, webhook };
package/dist/webhook.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as AlertChannel } from './types-Cz47Boks.js';
1
+ import { a as AlertChannel } from './types-DLnlUie8.js';
2
2
 
3
3
  interface WebhookOptions {
4
4
  url: string;
@@ -12,12 +12,23 @@ interface WebhookOptions {
12
12
  secret?: string;
13
13
  }
14
14
  /**
15
- * POSTs the alert as JSON to any URL. The body is the Alert object:
16
- * { type, job, title, message, run, details, triage, at, definition }.
17
- * A redirect is an error: point the url at where the receiver really is.
15
+ * POSTs the alert as JSON to any URL. The body is the Alert object after a
16
+ * version: { schema: 1, type, job, title, message, run, details, triage, at,
17
+ * definition }. A redirect is an error: point the url at where the receiver
18
+ * really is.
18
19
  */
19
20
  declare function webhook(options: WebhookOptions): AlertChannel;
20
- /** HMAC-SHA256 as lowercase hex, with Web Crypto so it runs on Workers and Deno too. */
21
- declare function hmacSha256Hex(secret: string, body: string): Promise<string>;
21
+ /**
22
+ * The webhook's signature of a body: the HMAC-SHA256 of the body with the
23
+ * secret, as lowercase hex. A request carries it as
24
+ * `X-CronWatch-Signature: sha256=<signature>`, so a receiver checks one with
25
+ * the raw body it got (before any JSON parsing) and a constant-time compare.
26
+ * Web Crypto, so it runs on Workers and Deno too. Every port has it, named
27
+ * the same.
28
+ */
29
+ declare function signature(secret: string, body: string): Promise<string>;
30
+
31
+ /** @deprecated Renamed `signature`, the name every port uses; this name is no longer exported from 1.0. */
32
+ declare const hmacSha256Hex: typeof signature;
22
33
 
23
- export { type WebhookOptions, hmacSha256Hex, webhook };
34
+ export { type WebhookOptions, hmacSha256Hex, signature, webhook };
package/dist/webhook.js CHANGED
@@ -12,6 +12,7 @@ function postable(url) {
12
12
 
13
13
  // src/alerts/webhook.ts
14
14
  var TIMEOUT_MS = 1e4;
15
+ var WEBHOOK_SCHEMA = 1;
15
16
  function origin(url) {
16
17
  try {
17
18
  return new URL(url).origin;
@@ -24,24 +25,27 @@ function webhook(options) {
24
25
  return {
25
26
  name: "webhook",
26
27
  async send(alert) {
27
- const body = JSON.stringify(alert);
28
+ const body = JSON.stringify({ schema: WEBHOOK_SCHEMA, ...alert });
28
29
  const headers = { "content-type": "application/json", "user-agent": "cronwatch" };
29
30
  for (const [name, value] of Object.entries(options.headers ?? {})) headers[name] = typeof value === "string" ? value.trim() : value;
30
31
  if (options.secret) {
31
- headers["x-cronwatch-signature"] = `sha256=${await hmacSha256Hex(options.secret, body)}`;
32
+ headers["x-cronwatch-signature"] = `sha256=${await signature(options.secret, body)}`;
32
33
  }
33
34
  const response = await fetch(postable(options.url), { method: "POST", headers, body, redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS) });
34
35
  if (!response.ok) throw new Error(`Webhook ${origin(options.url)} answered ${response.status}`);
35
36
  }
36
37
  };
37
38
  }
38
- async function hmacSha256Hex(secret, body) {
39
+ async function signature(secret, body) {
39
40
  const encoder = new TextEncoder();
40
41
  const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
41
- const signature = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(body)));
42
- return Array.from(signature, (b) => b.toString(16).padStart(2, "0")).join("");
42
+ const signature2 = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(body)));
43
+ return Array.from(signature2, (b) => b.toString(16).padStart(2, "0")).join("");
43
44
  }
44
45
 
45
- export { hmacSha256Hex, webhook };
46
+ // src/entries/webhook.ts
47
+ var hmacSha256Hex = signature;
48
+
49
+ export { hmacSha256Hex, signature, webhook };
46
50
  //# sourceMappingURL=webhook.js.map
47
51
  //# sourceMappingURL=webhook.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/webhook.ts"],"names":[],"mappings":";AAiCO,SAAS,SAAS,GAAA,EAAqB;AAC5C,EAAA,IAAI,QAAA;AACJ,EAAA,IAAI;AACF,IAAA,QAAA,GAAW,IAAI,GAAA,CAAI,GAAG,CAAA,CAAE,QAAA;AAAA,EAC1B,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,MAAM,yDAAyD,CAAA;AAAA,EAC3E;AACA,EAAA,IAAI,QAAA,KAAa,WAAW,QAAA,KAAa,QAAA,QAAgB,IAAI,KAAA,CAAM,CAAA,+CAAA,EAAkD,QAAQ,CAAA,CAAE,CAAA;AAC/H,EAAA,OAAO,GAAA;AACT;;;AC3BA,IAAM,UAAA,GAAa,GAAA;AAEnB,SAAS,OAAO,GAAA,EAAqB;AACnC,EAAA,IAAI;AACF,IAAA,OAAO,IAAI,GAAA,CAAI,GAAG,CAAA,CAAE,MAAA;AAAA,EACtB,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,eAAA;AAAA,EACT;AACF;AAOO,SAAS,QAAQ,OAAA,EAAuC;AAC7D,EAAA,IAAI,CAAC,OAAA,CAAQ,GAAA,EAAK,MAAM,IAAI,MAAM,uBAAuB,CAAA;AACzD,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,SAAA;AAAA,IACN,MAAM,KAAK,KAAA,EAAO;AAChB,MAAA,MAAM,IAAA,GAAO,IAAA,CAAK,SAAA,CAAU,KAAK,CAAA;AACjC,MAAA,MAAM,OAAA,GAAkC,EAAE,cAAA,EAAgB,kBAAA,EAAoB,cAAc,WAAA,EAAY;AAExG,MAAA,KAAA,MAAW,CAAC,IAAA,EAAM,KAAK,KAAK,MAAA,CAAO,OAAA,CAAQ,QAAQ,OAAA,IAAW,EAAE,CAAA,EAAG,OAAA,CAAQ,IAAI,CAAA,GAAI,OAAO,UAAU,QAAA,GAAW,KAAA,CAAM,MAAK,GAAI,KAAA;AAC9H,MAAA,IAAI,QAAQ,MAAA,EAAQ;AAClB,QAAA,OAAA,CAAQ,uBAAuB,IAAI,CAAA,OAAA,EAAU,MAAM,cAAc,OAAA,CAAQ,MAAA,EAAQ,IAAI,CAAC,CAAA,CAAA;AAAA,MACxF;AAEA,MAAA,MAAM,WAAW,MAAM,KAAA,CAAM,SAAS,OAAA,CAAQ,GAAG,GAAG,EAAE,MAAA,EAAQ,QAAQ,OAAA,EAAS,IAAA,EAAM,UAAU,OAAA,EAAS,MAAA,EAAQ,YAAY,OAAA,CAAQ,UAAU,GAAG,CAAA;AAEjJ,MAAA,IAAI,CAAC,QAAA,CAAS,EAAA,EAAI,MAAM,IAAI,KAAA,CAAM,CAAA,QAAA,EAAW,MAAA,CAAO,OAAA,CAAQ,GAAG,CAAC,CAAA,UAAA,EAAa,QAAA,CAAS,MAAM,CAAA,CAAE,CAAA;AAAA,IAChG;AAAA,GACF;AACF;AAGA,eAAsB,aAAA,CAAc,QAAgB,IAAA,EAA+B;AACjF,EAAA,MAAM,OAAA,GAAU,IAAI,WAAA,EAAY;AAChC,EAAA,MAAM,MAAM,MAAM,MAAA,CAAO,OAAO,SAAA,CAAU,KAAA,EAAO,QAAQ,MAAA,CAAO,MAAM,GAAG,EAAE,IAAA,EAAM,QAAQ,IAAA,EAAM,SAAA,IAAa,KAAA,EAAO,CAAC,MAAM,CAAC,CAAA;AAC3H,EAAA,MAAM,SAAA,GAAY,IAAI,UAAA,CAAW,MAAM,MAAA,CAAO,MAAA,CAAO,IAAA,CAAK,MAAA,EAAQ,GAAA,EAAK,OAAA,CAAQ,MAAA,CAAO,IAAI,CAAC,CAAC,CAAA;AAC5F,EAAA,OAAO,KAAA,CAAM,IAAA,CAAK,SAAA,EAAW,CAAC,MAAM,CAAA,CAAE,QAAA,CAAS,EAAE,CAAA,CAAE,SAAS,CAAA,EAAG,GAAG,CAAC,CAAA,CAAE,KAAK,EAAE,CAAA;AAC9E","file":"webhook.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 { AlertChannel } from \"../types.js\";\nimport { postable } from \"./shared.js\";\n\nexport interface WebhookOptions {\n url: string;\n /** Extra request headers, for an Authorization header say. Values are trimmed. */\n headers?: Record<string, string>;\n /**\n * When set, each request carries `X-CronWatch-Signature: sha256=<hex>`,\n * the HMAC-SHA256 of the raw body with this secret, so the receiver can\n * verify it.\n */\n secret?: string;\n}\n\nconst TIMEOUT_MS = 10_000;\n\nfunction origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * POSTs the alert as JSON to any URL. The body is the Alert object:\n * { type, job, title, message, run, details, triage, at, definition }.\n * A redirect is an error: point the url at where the receiver really is.\n */\nexport function webhook(options: WebhookOptions): AlertChannel {\n if (!options.url) throw new Error(\"webhook() needs a url\");\n return {\n name: \"webhook\",\n async send(alert) {\n const body = JSON.stringify(alert);\n const headers: Record<string, string> = { \"content-type\": \"application/json\", \"user-agent\": \"cronwatch\" };\n // A pasted Authorization value often carries a stray space or newline, which fetch would refuse.\n for (const [name, value] of Object.entries(options.headers ?? {})) headers[name] = typeof value === \"string\" ? value.trim() : value;\n if (options.secret) {\n headers[\"x-cronwatch-signature\"] = `sha256=${await hmacSha256Hex(options.secret, body)}`;\n }\n // A redirect is refused, not followed: the headers (and the signature) would go with it.\n const response = await fetch(postable(options.url), { method: \"POST\", headers, body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n // Only the origin: a webhook URL's path or query often is the credential.\n if (!response.ok) throw new Error(`Webhook ${origin(options.url)} answered ${response.status}`);\n },\n };\n}\n\n/** HMAC-SHA256 as lowercase hex, with Web Crypto so it runs on Workers and Deno too. */\nexport async function hmacSha256Hex(secret: string, body: string): Promise<string> {\n const encoder = new TextEncoder();\n const key = await crypto.subtle.importKey(\"raw\", encoder.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n const signature = new Uint8Array(await crypto.subtle.sign(\"HMAC\", key, encoder.encode(body)));\n return Array.from(signature, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n"]}
1
+ {"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/webhook.ts","../src/entries/webhook.ts"],"names":["signature"],"mappings":"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","file":"webhook.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 { AlertChannel } from \"../types.js\";\nimport { postable } from \"./shared.js\";\n\nexport interface WebhookOptions {\n url: string;\n /** Extra request headers, for an Authorization header say. Values are trimmed. */\n headers?: Record<string, string>;\n /**\n * When set, each request carries `X-CronWatch-Signature: sha256=<hex>`,\n * the HMAC-SHA256 of the raw body with this secret, so the receiver can\n * verify it.\n */\n secret?: string;\n}\n\nconst TIMEOUT_MS = 10_000;\n\n/**\n * The payload's version, sent as its first field. It goes up only if a\n * major release changes the payload in a way that is not additive; see\n * https://cronwatch.dev/schemas/webhook/1.json.\n */\nexport const WEBHOOK_SCHEMA = 1;\n\nfunction origin(url: string): string {\n try {\n return new URL(url).origin;\n } catch {\n return \"(invalid URL)\";\n }\n}\n\n/**\n * POSTs the alert as JSON to any URL. The body is the Alert object after a\n * version: { schema: 1, type, job, title, message, run, details, triage, at,\n * definition }. A redirect is an error: point the url at where the receiver\n * really is.\n */\nexport function webhook(options: WebhookOptions): AlertChannel {\n if (!options.url) throw new Error(\"webhook() needs a url\");\n return {\n name: \"webhook\",\n async send(alert) {\n const body = JSON.stringify({ schema: WEBHOOK_SCHEMA, ...alert });\n const headers: Record<string, string> = { \"content-type\": \"application/json\", \"user-agent\": \"cronwatch\" };\n // A pasted Authorization value often carries a stray space or newline, which fetch would refuse.\n for (const [name, value] of Object.entries(options.headers ?? {})) headers[name] = typeof value === \"string\" ? value.trim() : value;\n if (options.secret) {\n headers[\"x-cronwatch-signature\"] = `sha256=${await signature(options.secret, body)}`;\n }\n // A redirect is refused, not followed: the headers (and the signature) would go with it.\n const response = await fetch(postable(options.url), { method: \"POST\", headers, body, redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n // Only the origin: a webhook URL's path or query often is the credential.\n if (!response.ok) throw new Error(`Webhook ${origin(options.url)} answered ${response.status}`);\n },\n };\n}\n\n/**\n * The webhook's signature of a body: the HMAC-SHA256 of the body with the\n * secret, as lowercase hex. A request carries it as\n * `X-CronWatch-Signature: sha256=<signature>`, so a receiver checks one with\n * the raw body it got (before any JSON parsing) and a constant-time compare.\n * Web Crypto, so it runs on Workers and Deno too. Every port has it, named\n * the same.\n */\nexport async function signature(secret: string, body: string): Promise<string> {\n const encoder = new TextEncoder();\n const key = await crypto.subtle.importKey(\"raw\", encoder.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n const signature = new Uint8Array(await crypto.subtle.sign(\"HMAC\", key, encoder.encode(body)));\n return Array.from(signature, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n","// @cronwatch/sdk/webhook: the channel, and signature(), the one helper for\n// a receiver, named the same in every port.\nimport * as internal from \"../alerts/webhook.js\";\n\nexport { signature, webhook } from \"../alerts/webhook.js\";\nexport type { WebhookOptions } from \"../alerts/webhook.js\";\n\n/** @deprecated Renamed `signature`, the name every port uses; this name is no longer exported from 1.0. */\nexport const hmacSha256Hex = internal.signature;\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cronwatch/sdk",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Cron and scheduled-job monitoring that lives inside your app. Wrap a job, mount a route, get told when it is missed, failed, slow or over budget. No server to run.",
5
5
  "license": "MIT",
6
6
  "repository": {