@cronwatch/sdk 0.14.0 → 0.14.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.
Files changed (67) hide show
  1. package/dist/bugsnag.cjs +19 -4
  2. package/dist/bugsnag.cjs.map +1 -1
  3. package/dist/bugsnag.js +19 -4
  4. package/dist/bugsnag.js.map +1 -1
  5. package/dist/datadog.cjs +19 -4
  6. package/dist/datadog.cjs.map +1 -1
  7. package/dist/datadog.js +19 -4
  8. package/dist/datadog.js.map +1 -1
  9. package/dist/discord.cjs +27 -9
  10. package/dist/discord.cjs.map +1 -1
  11. package/dist/discord.js +27 -9
  12. package/dist/discord.js.map +1 -1
  13. package/dist/honeybadger.cjs +19 -4
  14. package/dist/honeybadger.cjs.map +1 -1
  15. package/dist/honeybadger.js +19 -4
  16. package/dist/honeybadger.js.map +1 -1
  17. package/dist/index.cjs +33 -17
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +33 -17
  22. package/dist/index.js.map +1 -1
  23. package/dist/mailgun.cjs +19 -4
  24. package/dist/mailgun.cjs.map +1 -1
  25. package/dist/mailgun.js +19 -4
  26. package/dist/mailgun.js.map +1 -1
  27. package/dist/newrelic.cjs +19 -4
  28. package/dist/newrelic.cjs.map +1 -1
  29. package/dist/newrelic.js +19 -4
  30. package/dist/newrelic.js.map +1 -1
  31. package/dist/postmark.cjs +19 -4
  32. package/dist/postmark.cjs.map +1 -1
  33. package/dist/postmark.js +19 -4
  34. package/dist/postmark.js.map +1 -1
  35. package/dist/resend.cjs +19 -4
  36. package/dist/resend.cjs.map +1 -1
  37. package/dist/resend.js +19 -4
  38. package/dist/resend.js.map +1 -1
  39. package/dist/rollbar.cjs +19 -4
  40. package/dist/rollbar.cjs.map +1 -1
  41. package/dist/rollbar.js +19 -4
  42. package/dist/rollbar.js.map +1 -1
  43. package/dist/sendgrid.cjs +19 -4
  44. package/dist/sendgrid.cjs.map +1 -1
  45. package/dist/sendgrid.js +19 -4
  46. package/dist/sendgrid.js.map +1 -1
  47. package/dist/sentry.cjs +19 -4
  48. package/dist/sentry.cjs.map +1 -1
  49. package/dist/sentry.js +19 -4
  50. package/dist/sentry.js.map +1 -1
  51. package/dist/ses.cjs +19 -4
  52. package/dist/ses.cjs.map +1 -1
  53. package/dist/ses.js +19 -4
  54. package/dist/ses.js.map +1 -1
  55. package/dist/slack.cjs +27 -9
  56. package/dist/slack.cjs.map +1 -1
  57. package/dist/slack.js +27 -9
  58. package/dist/slack.js.map +1 -1
  59. package/dist/twilio.cjs +19 -4
  60. package/dist/twilio.cjs.map +1 -1
  61. package/dist/twilio.js +19 -4
  62. package/dist/twilio.js.map +1 -1
  63. package/dist/webhook.cjs +29 -7
  64. package/dist/webhook.cjs.map +1 -1
  65. package/dist/webhook.js +29 -7
  66. package/dist/webhook.js.map +1 -1
  67. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/duration.ts","../src/alerts/shared.ts","../src/alerts/sentry.ts","../src/entries/sentry.ts"],"names":["parseDsn"],"mappings":";;;AAsFO,IAAM,aAAA,GAAgB,YAAA;AAEtB,IAAM,YAAA,GAAe,eAAA;AAQrB,SAAS,QAAQ,EAAA,EAA2B;AACjD,EAAA,OAAO,EAAA,IAAM,iBAAiB,EAAA,IAAM,YAAA,GAAe,IAAI,IAAA,CAAK,EAAE,CAAA,CAAE,WAAA,EAAY,GAAI,IAAA;AAClF;;;AC1FO,IAAM,UAAA,GAAa,GAAA;AAKnB,SAAS,SAAS,IAAA,EAA+B;AACtD,EAAA,IAAI,IAAA,KAAS,aAAa,OAAO,MAAA;AACjC,EAAA,IAAI,SAAS,MAAA,IAAU,IAAA,KAAS,aAAA,IAAiB,IAAA,KAAS,eAAe,OAAO,SAAA;AAChF,EAAA,OAAO,OAAA;AACT;AAGO,SAAS,OAAO,GAAA,EAAqB;AAC1C,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,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;AAGO,IAAM,cAAA,GAAiB,GAAA;AAW9B,eAAsB,KAAK,QAAA,EAAkB,GAAA,EAAa,IAAA,EAAyD,OAAA,GAAkC,EAAC,EAAsB;AAC1K,EAAA,MAAM,QAAA,GAAW,MAAM,KAAA,CAAM,QAAA,CAAS,GAAG,CAAA,EAAG,EAAE,MAAA,EAAQ,MAAA,EAAQ,OAAA,EAAS,IAAA,CAAK,SAAS,IAAA,EAAM,IAAA,CAAK,MAAM,QAAA,EAAU,OAAA,EAAS,QAAQ,WAAA,CAAY,OAAA,CAAQ,UAAU,CAAA,EAAG,CAAA;AAClK,EAAA,IAAI,CAAC,SAAS,EAAA,EAAI;AAChB,IAAA,MAAM,IAAA,GAAO,MAAM,SAAA,CAAU,QAAQ,CAAA;AACrC,IAAA,MAAM,IAAI,MAAM,CAAA,EAAG,QAAQ,IAAI,MAAA,CAAO,GAAG,CAAC,CAAA,UAAA,EAAa,QAAA,CAAS,MAAM,CAAA,EAAG,IAAA,GAAO,KAAK,SAAA,CAAU,IAAA,EAAM,OAAO,CAAC,CAAA,CAAA,GAAK,EAAE,CAAA,CAAE,CAAA;AAAA,EACxH;AACA,EAAA,MAAM,MAAM,QAAQ,CAAA;AACpB,EAAA,OAAO,QAAA;AACT;AAGO,IAAM,iBAAiB,EAAA,GAAK,IAAA;AASnC,eAAsB,UAAU,QAAA,EAAqC;AACnE,EAAA,MAAM,MAAA,GAAS,QAAA,CAAS,IAAA,EAAM,SAAA,EAAU;AACxC,EAAA,IAAI,CAAC,QAAQ,OAAO,EAAA;AACpB,EAAA,MAAM,SAAuB,EAAC;AAC9B,EAAA,IAAI,IAAA,GAAO,CAAA;AACX,EAAA,IAAI;AACF,IAAA,OAAO,OAAO,cAAA,EAAgB;AAC5B,MAAA,MAAM,EAAE,IAAA,EAAM,KAAA,EAAM,GAAI,MAAM,OAAO,IAAA,EAAK;AAC1C,MAAA,IAAI,IAAA,EAAM;AACV,MAAA,MAAM,IAAA,GAAO,KAAA,CAAM,QAAA,CAAS,CAAA,EAAG,iBAAiB,IAAI,CAAA;AACpD,MAAA,MAAA,CAAO,KAAK,IAAI,CAAA;AAChB,MAAA,IAAA,IAAQ,IAAA,CAAK,UAAA;AAAA,IACf;AAAA,EACF,CAAA,CAAA,MAAQ;AAAA,EAER,CAAA,SAAE;AACA,IAAA,MAAM,MAAA,CAAO,MAAA,EAAO,CAAE,KAAA,CAAM,MAAM;AAAA,IAAC,CAAC,CAAA;AAAA,EACtC;AACA,EAAA,MAAM,KAAA,GAAQ,IAAI,UAAA,CAAW,IAAI,CAAA;AACjC,EAAA,IAAI,EAAA,GAAK,CAAA;AACT,EAAA,KAAA,MAAW,SAAS,MAAA,EAAQ;AAC1B,IAAA,KAAA,CAAM,GAAA,CAAI,OAAO,EAAE,CAAA;AACnB,IAAA,EAAA,IAAM,KAAA,CAAM,UAAA;AAAA,EACd;AACA,EAAA,OAAO,IAAI,WAAA,EAAY,CAAE,MAAA,CAAO,KAAK,CAAA;AACvC;AAMA,eAAsB,MAAM,QAAA,EAAmC;AAC7D,EAAA,IAAI;AACF,IAAA,MAAM,QAAA,CAAS,MAAM,MAAA,EAAO;AAAA,EAC9B,CAAA,CAAA,MAAQ;AAAA,EAER;AACF;AAkBO,SAAS,SAAA,CAAU,IAAA,EAAc,OAAA,GAAkC,EAAC,EAAW;AACpF,EAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,MAAA,CAAO,CAAC,CAAA,KAAmB,OAAO,CAAA,KAAM,QAAA,IAAY,CAAA,CAAE,MAAA,IAAU,CAAC,CAAA;AACtF,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,MAAA,CAAO,CAAC,CAAA,EAAG,CAAA,KAAM,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,CAAA,CAAE,MAAM,CAAA,EAAG,CAAC,CAAA;AAC9D,EAAA,IAAI,IAAA,GAAO,GAAA,CAAI,IAAA,EAAM,cAAA,GAAiB,OAAO,CAAA;AAC7C,EAAA,KAAA,MAAW,MAAA,IAAU,MAAM,IAAA,GAAO,IAAA,CAAK,MAAM,MAAM,CAAA,CAAE,KAAK,YAAY,CAAA;AACtE,EAAA,OAAO,GAAA,CAAI,MAAM,cAAc,CAAA;AACjC;AAGO,SAAS,QAAQ,KAAA,EAAwB;AAC9C,EAAA,OAAO,OAAO,KAAA,KAAU,QAAA,GAAW,KAAA,CAAM,MAAK,GAAI,EAAA;AACpD;AAaO,SAAS,IAAI,KAAA,EAAyC;AAC3D,EAAA,OAAO,KAAA,CAAM,KAAK,KAAA,YAAiB,UAAA,GAAa,QAAQ,IAAI,UAAA,CAAW,KAAK,CAAA,EAAG,CAAC,MAAM,CAAA,CAAE,QAAA,CAAS,EAAE,CAAA,CAAE,QAAA,CAAS,GAAG,GAAG,CAAC,CAAA,CAAE,IAAA,CAAK,EAAE,CAAA;AAChI;AAEA,eAAsB,UAAU,IAAA,EAA+B;AAC7D,EAAA,OAAO,GAAA,CAAI,MAAM,MAAA,CAAO,MAAA,CAAO,MAAA,CAAO,SAAA,EAAW,IAAI,WAAA,EAAY,CAAE,MAAA,CAAO,IAAI,CAAC,CAAC,CAAA;AAClF;AAOA,eAAsB,QAAQ,KAAA,EAA+B;AAC3D,EAAA,OAAA,CAAQ,MAAM,SAAA,CAAU,CAAA,EAAG,KAAA,CAAM,GAAG;AAAA,EAAK,MAAM,IAAI;AAAA,EAAK,MAAM,EAAE,CAAA,CAAE,CAAA,EAAG,KAAA,CAAM,GAAG,EAAE,CAAA;AAClF;AAQO,SAAS,GAAA,CAAI,MAAc,GAAA,EAAqB;AACrD,EAAA,IAAI,IAAA,CAAK,MAAA,IAAU,GAAA,EAAK,OAAO,IAAA;AAC/B,EAAA,IAAI,GAAA,GAAM,GAAA;AACV,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,UAAA,CAAW,GAAA,GAAM,CAAC,CAAA;AACpC,EAAA,IAAI,IAAA,IAAQ,KAAA,IAAU,IAAA,IAAQ,KAAA,EAAQ,GAAA,IAAO,CAAA;AAC7C,EAAA,OAAO,IAAA,CAAK,KAAA,CAAM,CAAA,EAAG,GAAG,CAAA;AAC1B;AAGO,SAAS,WAAW,KAAA,EAA8C;AACvE,EAAA,MAAM,MAAM,KAAA,CAAM,GAAA;AAClB,EAAA,IAAI,CAAC,KAAK,OAAO,IAAA;AACjB,EAAA,OAAO,EAAE,EAAA,EAAI,GAAA,CAAI,EAAA,EAAI,MAAA,EAAQ,IAAI,MAAA,EAAQ,SAAA,EAAW,OAAA,CAAQ,GAAA,CAAI,SAAS,CAAA,EAAG,UAAA,EAAY,IAAI,UAAA,EAAY,OAAA,EAAS,IAAI,OAAA,EAAQ;AAC/H;;;ACtKO,SAAS,SAAS,GAAA,EAAkB;AACzC,EAAA,IAAI,GAAA;AACJ,EAAA,IAAI;AACF,IAAA,GAAA,GAAM,IAAI,IAAI,GAAG,CAAA;AAAA,EACnB,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,MAAM,4BAA4B,CAAA;AAAA,EAC9C;AACA,EAAA,MAAM,WAAW,GAAA,CAAI,QAAA,CAAS,MAAM,GAAG,CAAA,CAAE,OAAO,OAAO,CAAA;AACvD,EAAA,MAAM,OAAA,GAAU,SAAS,GAAA,EAAI;AAC7B,EAAA,IAAI,CAAC,GAAA,CAAI,QAAA,IAAY,CAAC,OAAA,IAAW,CAAC,OAAA,CAAQ,IAAA,CAAK,OAAO,CAAA,EAAG,MAAM,IAAI,MAAM,0DAA0D,CAAA;AACnI,EAAA,MAAM,MAAA,GAAS,SAAS,MAAA,GAAS,CAAA,CAAA,EAAI,SAAS,IAAA,CAAK,GAAG,CAAC,CAAA,CAAA,GAAK,EAAA;AAC5D,EAAA,OAAO,EAAE,QAAA,EAAU,CAAA,EAAG,GAAA,CAAI,QAAQ,KAAK,GAAA,CAAI,IAAI,CAAA,EAAG,MAAM,QAAQ,OAAO,CAAA,UAAA,CAAA,EAAc,WAAW,kBAAA,CAAmB,GAAA,CAAI,QAAQ,CAAA,EAAE;AACnI;AAGO,SAAS,OAAO,OAAA,EAAsC;AAE3D,EAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,OAAA,CAAQ,GAAG,CAAA;AAC/B,EAAA,IAAI,CAAC,GAAA,EAAK,MAAM,IAAI,MAAM,sBAAsB,CAAA;AAChD,EAAA,MAAM,EAAE,QAAA,EAAU,SAAA,EAAU,GAAI,SAAS,GAAG,CAAA;AAC5C,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,QAAA;AAAA,IACN,MAAM,KAAK,KAAA,EAAO;AAChB,MAAA,IAAI,KAAA,CAAM,IAAA,KAAS,WAAA,IAAe,OAAA,CAAQ,cAAc,KAAA,EAAO;AAC/D,MAAA,MAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,KAAK,CAAA;AACnC,MAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,IAAA,GAAO,KAAK,CAAA;AACjC,MAAA,MAAM,KAAA,GAAQ;AAAA,QACZ,QAAA,EAAU,OAAA;AAAA,QACV,SAAA,EAAW,MAAM,EAAA,GAAK,GAAA;AAAA,QACtB,QAAA,EAAU,OAAA;AAAA,QACV,KAAA,EAAO,QAAA,CAAS,KAAA,CAAM,IAAI,CAAA;AAAA,QAC1B,MAAA,EAAQ,WAAA;AAAA,QACR,aAAa,KAAA,CAAM,GAAA;AAAA,QACnB,WAAA,EAAa,QAAQ,WAAA,IAAe,YAAA;AAAA,QACpC,GAAI,QAAQ,OAAA,GAAU,EAAE,SAAS,OAAA,CAAQ,OAAA,KAAY,EAAC;AAAA;AAAA,QAEtD,UAAU,EAAE,SAAA,EAAW,GAAA,CAAI,CAAA,EAAG,MAAM,KAAK;;AAAA,EAAO,KAAA,CAAM,OAAO,CAAA,CAAA,EAAI,IAAI,CAAA,EAAE;AAAA,QACvE,aAAa,CAAC,WAAA,EAAa,KAAA,CAAM,GAAA,EAAK,MAAM,IAAI,CAAA;AAAA,QAChD,IAAA,EAAM,EAAE,GAAA,EAAK,GAAA,CAAI,KAAA,CAAM,KAAK,GAAG,CAAA,EAAG,IAAA,EAAM,KAAA,CAAM,IAAA,EAAK;AAAA,QACnD,KAAA,EAAO;AAAA,UACL,GAAI,MAAM,MAAA,GAAS,EAAE,QAAQ,KAAA,CAAM,MAAA,KAAW,EAAC;AAAA,UAC/C,GAAI,IAAA,GAAO,EAAE,IAAA,KAAS,EAAC;AAAA,UACvB,SAAS,KAAA,CAAM,OAAA;AAAA,UACf,GAAA,EAAK,WAAW,KAAK;AAAA;AACvB,OACF;AACA,MAAA,MAAM,OAAA,GAAU,IAAA,CAAK,SAAA,CAAU,KAAK,CAAA;AACpC,MAAA,MAAM,QAAA,GAAW;AAAA,QACf,IAAA,CAAK,SAAA,CAAU,EAAE,QAAA,EAAU,SAAS,CAAA;AAAA,QACpC,IAAA,CAAK,SAAA,CAAU,EAAE,IAAA,EAAM,SAAS,YAAA,EAAc,kBAAA,EAAoB,MAAA,EAAQ,IAAI,aAAY,CAAE,MAAA,CAAO,OAAO,CAAA,CAAE,QAAQ,CAAA;AAAA,QACpH;AAAA,OACF,CAAE,IAAA,CAAK,IAAI,CAAA,GAAI,IAAA;AACf,MAAA,MAAM,IAAA,CAAK,UAAU,QAAA,EAAU;AAAA,QAC7B,OAAA,EAAS;AAAA,UACP,cAAA,EAAgB,+BAAA;AAAA,UAChB,eAAA,EAAiB,uCAAuC,SAAS,CAAA,yBAAA;AAAA,SACnE;AAAA,QACA,IAAA,EAAM;AAAA,OACR,EAAG,CAAC,SAAS,CAAC,CAAA;AAAA,IAChB;AAAA,GACF;AACF;;;AC9EO,IAAMA,SAAAA,GAAoB","file":"sentry.cjs","sourcesContent":["import type { Duration } from \"./types.js\";\n\nconst UNIT_MS: Record<string, number> = {\n ms: 1,\n s: 1000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n w: 604_800_000,\n};\n\n/**\n * The longest duration string read, in characters (code points). No real\n * duration comes near it, and the pattern below is quadratic on a long run of\n * digits, so a longer string is refused before it is read.\n */\nconst MAX_LENGTH = 64;\n/** How much of a refused, overlong string its error quotes. */\nconst QUOTED = 32;\n\n/**\n * \"15m\" -> 900000. Accepts a plain number of milliseconds, and compound\n * strings such as \"1h30m\". Whitespace between parts is fine.\n */\nexport function parseDuration(value: Duration, label = \"duration\"): number {\n if (typeof value === \"number\") {\n if (!Number.isFinite(value) || value < 0) throw new Error(`${label} must be a non-negative number of milliseconds`);\n return value;\n }\n if (value.length > MAX_LENGTH) tooLong(value, label);\n const text = value.trim().toLowerCase();\n if (text === \"\") throw new Error(`${label} is empty`);\n const re = /(\\d+(?:\\.\\d+)?)\\s*(ms|s|m|h|d|w)/g;\n let total = 0;\n let consumed = \"\";\n let match: RegExpExecArray | null;\n while ((match = re.exec(text)) !== null) {\n total += parseFloat(match[1]!) * UNIT_MS[match[2]!]!;\n consumed += match[0];\n }\n if (consumed.replace(/\\s+/g, \"\") !== text.replace(/\\s+/g, \"\")) {\n throw new Error(`${label} \"${value}\" is not a duration like \"15m\", \"1h30m\", or \"90s\"`);\n }\n return Math.round(total);\n}\n\n/** Throws if `value` is over MAX_LENGTH characters, quoting the first QUOTED. */\nfunction tooLong(value: string, label: string): void {\n let count = 0;\n let head = \"\";\n for (const char of value) {\n if (count < QUOTED) head += char;\n if (++count > MAX_LENGTH) {\n throw new Error(`${label} \"${head}...\" is too long for a duration (more than ${MAX_LENGTH} characters)`);\n }\n }\n}\n\n/** 90000 -> \"1m 30s\". For messages, not for parsing back. */\nexport function formatDuration(ms: number): string {\n if (!Number.isFinite(ms)) return \"?\";\n if (ms < 1000) return `${Math.round(ms)}ms`;\n const parts: string[] = [];\n let rest = Math.round(ms / 1000);\n const units: [string, number][] = [[\"d\", 86_400], [\"h\", 3_600], [\"m\", 60], [\"s\", 1]];\n for (const [unit, size] of units) {\n if (rest >= size) {\n const n = Math.floor(rest / size);\n rest -= n * size;\n parts.push(`${n}${unit}`);\n }\n if (parts.length === 2) break;\n }\n return parts.join(\" \") || \"0s\";\n}\n\n/** \"5 minutes ago\", \"in 2h\". Relative to `now`. */\nexport function formatRelative(at: number, now: number): string {\n const diff = at - now;\n const abs = Math.abs(diff);\n const text = abs < 5_000 ? \"now\" : formatDuration(abs);\n if (abs < 5_000) return text;\n return diff < 0 ? `${text} ago` : `in ${text}`;\n}\n\n/** The first millisecond written as a date: 0001-01-01T00:00:00.000Z. */\nexport const FIRST_DATE_MS = -62_135_596_800_000;\n/** The last millisecond written as a date: 9999-12-31T23:59:59.999Z. */\nexport const LAST_DATE_MS = 253_402_300_799_999;\n\n/**\n * \"2026-01-05T09:30:00.000Z\", or null for a time before the year 1 or after\n * 9999. A start read from another process's row, or a damaged one, can be\n * any number; outside those years it is not written as a date at all (and\n * past JavaScript's Date range, toISOString would throw).\n */\nexport function isoTime(at: number): string | null {\n return at >= FIRST_DATE_MS && at <= LAST_DATE_MS ? new Date(at).toISOString() : null;\n}\n\n/** The words that stand in for a time isoTime() does not write: \"before 0001-01-01 00:00:00 UTC\" or \"after 9999-12-31 23:59:59 UTC\". */\nexport function beyondDates(at: number): string {\n return at > LAST_DATE_MS ? \"after 9999-12-31 23:59:59 UTC\" : \"before 0001-01-01 00:00:00 UTC\";\n}\n","/**\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\" || type === \"under_floor\") 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 const text = await errorText(response);\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n await drain(response);\n return response;\n}\n\n/** The most of an error body read (64 KiB), far more than any secret plus ERROR_BODY_MAX needs. */\nexport const ERROR_READ_MAX = 64 * 1024;\n\n/**\n * The start of an error answer's body, at most ERROR_READ_MAX bytes read\n * from the stream and the rest let go, so a provider (or something posing\n * as one) cannot make a send hold a body of any size. \"\" when it cannot be\n * read. A character cut at the end decodes as U+FFFD, far past what\n * errorBody keeps.\n */\nexport async function errorText(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n const chunks: Uint8Array[] = [];\n let size = 0;\n try {\n while (size < ERROR_READ_MAX) {\n const { done, value } = await reader.read();\n if (done) break;\n const take = value.subarray(0, ERROR_READ_MAX - size);\n chunks.push(take);\n size += take.byteLength;\n }\n } catch {\n // The status is enough; whatever was read is kept.\n } finally {\n await reader.cancel().catch(() => {});\n }\n const bytes = new Uint8Array(size);\n let at = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, at);\n at += chunk.byteLength;\n }\n return new TextDecoder().decode(bytes);\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\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 * Sentry, through the envelope endpoint.\n * Envelopes: https://develop.sentry.dev/sdk/data-model/envelopes/\n * Event payload: https://develop.sentry.dev/sdk/data-model/event-payloads/\n * DSN and X-Sentry-Auth: https://develop.sentry.dev/sdk/foundations/transport/authentication/\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { alertId, cut, post, runSummary, severity, trimmed } from \"./shared.js\";\n\nexport interface SentryOptions {\n /** The project's DSN, \"https://<key>@o0.ingest.sentry.io/<project>\". */\n dsn: string;\n /** Defaults to \"production\". */\n environment?: string;\n release?: string;\n /** Also send recoveries, as info events. Defaults to true. */\n recovered?: boolean;\n link?: (alert: Alert) => string;\n}\n\ninterface Dsn {\n endpoint: string;\n publicKey: string;\n}\n\nexport function parseDsn(dsn: string): Dsn {\n let url: URL;\n try {\n url = new URL(dsn);\n } catch {\n throw new Error(\"sentry() needs a valid dsn\");\n }\n const segments = url.pathname.split(\"/\").filter(Boolean);\n const project = segments.pop();\n if (!url.username || !project || !/^\\d+$/.test(project)) throw new Error(\"sentry() needs a dsn like https://<key>@<host>/<project>\");\n const prefix = segments.length ? `/${segments.join(\"/\")}` : \"\";\n return { endpoint: `${url.protocol}//${url.host}${prefix}/api/${project}/envelope/`, publicKey: decodeURIComponent(url.username) };\n}\n\n/** Sends alerts to Sentry as events, one issue per job and alert type. */\nexport function sentry(options: SentryOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const dsn = trimmed(options.dsn);\n if (!dsn) throw new Error(\"sentry() needs a dsn\");\n const { endpoint, publicKey } = parseDsn(dsn);\n return {\n name: \"sentry\",\n async send(alert) {\n if (alert.type === \"recovered\" && options.recovered === false) return;\n const eventId = await alertId(alert);\n const link = options.link?.(alert);\n const event = {\n event_id: eventId,\n timestamp: alert.at / 1000,\n platform: \"other\",\n level: severity(alert.type),\n logger: \"cronwatch\",\n transaction: alert.job,\n environment: options.environment ?? \"production\",\n ...(options.release ? { release: options.release } : {}),\n // The first line is the issue title.\n logentry: { formatted: cut(`${alert.title}\\n\\n${alert.message}`, 8192) },\n fingerprint: [\"cronwatch\", alert.job, alert.type],\n tags: { job: cut(alert.job, 199), type: alert.type },\n extra: {\n ...(alert.triage ? { triage: alert.triage } : {}),\n ...(link ? { link } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n };\n const payload = JSON.stringify(event);\n const envelope = [\n JSON.stringify({ event_id: eventId }),\n JSON.stringify({ type: \"event\", content_type: \"application/json\", length: new TextEncoder().encode(payload).length }),\n payload,\n ].join(\"\\n\") + \"\\n\";\n await post(\"Sentry\", endpoint, {\n headers: {\n \"content-type\": \"application/x-sentry-envelope\",\n \"x-sentry-auth\": `Sentry sentry_version=7, sentry_key=${publicKey}, sentry_client=cronwatch`,\n },\n body: envelope,\n }, [publicKey]);\n },\n };\n}\n","// @cronwatch/sdk/sentry: what the entry point exports. parseDsn is internal\n// to the channel; it is kept, deprecated, and leaves the exports in 1.0.\nimport * as internal from \"../alerts/sentry.js\";\n\nexport { sentry } from \"../alerts/sentry.js\";\nexport type { SentryOptions } from \"../alerts/sentry.js\";\n\n/** @deprecated Internal to the Sentry channel; leaves the exports in 1.0. */\nexport const parseDsn = internal.parseDsn;\n"]}
1
+ {"version":3,"sources":["../src/duration.ts","../src/alerts/shared.ts","../src/alerts/sentry.ts","../src/entries/sentry.ts"],"names":["parseDsn"],"mappings":"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","file":"sentry.cjs","sourcesContent":["import type { Duration } from \"./types.js\";\n\nconst UNIT_MS: Record<string, number> = {\n ms: 1,\n s: 1000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n w: 604_800_000,\n};\n\n/**\n * The longest duration string read, in characters (code points). No real\n * duration comes near it, and the pattern below is quadratic on a long run of\n * digits, so a longer string is refused before it is read.\n */\nconst MAX_LENGTH = 64;\n/** How much of a refused, overlong string its error quotes. */\nconst QUOTED = 32;\n\n/**\n * \"15m\" -> 900000. Accepts a plain number of milliseconds, and compound\n * strings such as \"1h30m\". Whitespace between parts is fine.\n */\nexport function parseDuration(value: Duration, label = \"duration\"): number {\n if (typeof value === \"number\") {\n if (!Number.isFinite(value) || value < 0) throw new Error(`${label} must be a non-negative number of milliseconds`);\n return value;\n }\n if (value.length > MAX_LENGTH) tooLong(value, label);\n const text = value.trim().toLowerCase();\n if (text === \"\") throw new Error(`${label} is empty`);\n const re = /(\\d+(?:\\.\\d+)?)\\s*(ms|s|m|h|d|w)/g;\n let total = 0;\n let consumed = \"\";\n let match: RegExpExecArray | null;\n while ((match = re.exec(text)) !== null) {\n total += parseFloat(match[1]!) * UNIT_MS[match[2]!]!;\n consumed += match[0];\n }\n if (consumed.replace(/\\s+/g, \"\") !== text.replace(/\\s+/g, \"\")) {\n throw new Error(`${label} \"${value}\" is not a duration like \"15m\", \"1h30m\", or \"90s\"`);\n }\n return Math.round(total);\n}\n\n/** Throws if `value` is over MAX_LENGTH characters, quoting the first QUOTED. */\nfunction tooLong(value: string, label: string): void {\n let count = 0;\n let head = \"\";\n for (const char of value) {\n if (count < QUOTED) head += char;\n if (++count > MAX_LENGTH) {\n throw new Error(`${label} \"${head}...\" is too long for a duration (more than ${MAX_LENGTH} characters)`);\n }\n }\n}\n\n/** 90000 -> \"1m 30s\". For messages, not for parsing back. */\nexport function formatDuration(ms: number): string {\n if (!Number.isFinite(ms)) return \"?\";\n if (ms < 1000) return `${Math.round(ms)}ms`;\n const parts: string[] = [];\n let rest = Math.round(ms / 1000);\n const units: [string, number][] = [[\"d\", 86_400], [\"h\", 3_600], [\"m\", 60], [\"s\", 1]];\n for (const [unit, size] of units) {\n if (rest >= size) {\n const n = Math.floor(rest / size);\n rest -= n * size;\n parts.push(`${n}${unit}`);\n }\n if (parts.length === 2) break;\n }\n return parts.join(\" \") || \"0s\";\n}\n\n/** \"5 minutes ago\", \"in 2h\". Relative to `now`. */\nexport function formatRelative(at: number, now: number): string {\n const diff = at - now;\n const abs = Math.abs(diff);\n const text = abs < 5_000 ? \"now\" : formatDuration(abs);\n if (abs < 5_000) return text;\n return diff < 0 ? `${text} ago` : `in ${text}`;\n}\n\n/** The first millisecond written as a date: 0001-01-01T00:00:00.000Z. */\nexport const FIRST_DATE_MS = -62_135_596_800_000;\n/** The last millisecond written as a date: 9999-12-31T23:59:59.999Z. */\nexport const LAST_DATE_MS = 253_402_300_799_999;\n\n/**\n * \"2026-01-05T09:30:00.000Z\", or null for a time before the year 1 or after\n * 9999. A start read from another process's row, or a damaged one, can be\n * any number; outside those years it is not written as a date at all (and\n * past JavaScript's Date range, toISOString would throw).\n */\nexport function isoTime(at: number): string | null {\n return at >= FIRST_DATE_MS && at <= LAST_DATE_MS ? new Date(at).toISOString() : null;\n}\n\n/** The words that stand in for a time isoTime() does not write: \"before 0001-01-01 00:00:00 UTC\" or \"after 9999-12-31 23:59:59 UTC\". */\nexport function beyondDates(at: number): string {\n return at > LAST_DATE_MS ? \"after 9999-12-31 23:59:59 UTC\" : \"before 0001-01-01 00:00:00 UTC\";\n}\n","/**\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\" || type === \"under_floor\") 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 parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${parsed.protocol}`);\n if (parsed.username || parsed.password) throw new Error(\"URLs containing credentials cannot be posted to\");\n return url;\n}\n\n/** Fetch can quote credentials in construction errors. Only safe diagnostics leave this boundary. */\nexport async function fetchPost(url: string, init: { headers: Record<string, string>; body: string }): Promise<Response> {\n const target = postable(url);\n try {\n new Headers(init.headers);\n } catch {\n throw new Error(\"request headers are not valid\");\n }\n try {\n return await fetch(target, { ...init, method: \"POST\", redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n } catch (error) {\n if ((error as Error)?.name === \"TimeoutError\") throw new DOMException(\"The operation was aborted due to timeout\", \"TimeoutError\");\n throw new Error(`POST ${origin(target)} failed`);\n }\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 fetchPost(url, init);\n if (!response.ok) {\n const text = await errorText(response);\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n await drain(response);\n return response;\n}\n\n/** The most of an error body read (64 KiB), far more than any secret plus ERROR_BODY_MAX needs. */\nexport const ERROR_READ_MAX = 64 * 1024;\n\n/**\n * The start of an error answer's body, at most ERROR_READ_MAX bytes read\n * from the stream and the rest let go, so a provider (or something posing\n * as one) cannot make a send hold a body of any size. \"\" when it cannot be\n * read. A character cut at the end decodes as U+FFFD, far past what\n * errorBody keeps.\n */\nexport async function errorText(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n const chunks: Uint8Array[] = [];\n let size = 0;\n try {\n while (size < ERROR_READ_MAX) {\n const { done, value } = await reader.read();\n if (done) break;\n const take = value.subarray(0, ERROR_READ_MAX - size);\n chunks.push(take);\n size += take.byteLength;\n }\n } catch {\n // The status is enough; whatever was read is kept.\n } finally {\n await reader.cancel().catch(() => {});\n }\n const bytes = new Uint8Array(size);\n let at = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, at);\n at += chunk.byteLength;\n }\n return new TextDecoder().decode(bytes);\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\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 * Sentry, through the envelope endpoint.\n * Envelopes: https://develop.sentry.dev/sdk/data-model/envelopes/\n * Event payload: https://develop.sentry.dev/sdk/data-model/event-payloads/\n * DSN and X-Sentry-Auth: https://develop.sentry.dev/sdk/foundations/transport/authentication/\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { alertId, cut, post, runSummary, severity, trimmed } from \"./shared.js\";\n\nexport interface SentryOptions {\n /** The project's DSN, \"https://<key>@o0.ingest.sentry.io/<project>\". */\n dsn: string;\n /** Defaults to \"production\". */\n environment?: string;\n release?: string;\n /** Also send recoveries, as info events. Defaults to true. */\n recovered?: boolean;\n link?: (alert: Alert) => string;\n}\n\ninterface Dsn {\n endpoint: string;\n publicKey: string;\n}\n\nexport function parseDsn(dsn: string): Dsn {\n let url: URL;\n try {\n url = new URL(dsn);\n } catch {\n throw new Error(\"sentry() needs a valid dsn\");\n }\n const segments = url.pathname.split(\"/\").filter(Boolean);\n const project = segments.pop();\n if (!url.username || !project || !/^\\d+$/.test(project)) throw new Error(\"sentry() needs a dsn like https://<key>@<host>/<project>\");\n const prefix = segments.length ? `/${segments.join(\"/\")}` : \"\";\n return { endpoint: `${url.protocol}//${url.host}${prefix}/api/${project}/envelope/`, publicKey: decodeURIComponent(url.username) };\n}\n\n/** Sends alerts to Sentry as events, one issue per job and alert type. */\nexport function sentry(options: SentryOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const dsn = trimmed(options.dsn);\n if (!dsn) throw new Error(\"sentry() needs a dsn\");\n const { endpoint, publicKey } = parseDsn(dsn);\n return {\n name: \"sentry\",\n async send(alert) {\n if (alert.type === \"recovered\" && options.recovered === false) return;\n const eventId = await alertId(alert);\n const link = options.link?.(alert);\n const event = {\n event_id: eventId,\n timestamp: alert.at / 1000,\n platform: \"other\",\n level: severity(alert.type),\n logger: \"cronwatch\",\n transaction: alert.job,\n environment: options.environment ?? \"production\",\n ...(options.release ? { release: options.release } : {}),\n // The first line is the issue title.\n logentry: { formatted: cut(`${alert.title}\\n\\n${alert.message}`, 8192) },\n fingerprint: [\"cronwatch\", alert.job, alert.type],\n tags: { job: cut(alert.job, 199), type: alert.type },\n extra: {\n ...(alert.triage ? { triage: alert.triage } : {}),\n ...(link ? { link } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n };\n const payload = JSON.stringify(event);\n const envelope = [\n JSON.stringify({ event_id: eventId }),\n JSON.stringify({ type: \"event\", content_type: \"application/json\", length: new TextEncoder().encode(payload).length }),\n payload,\n ].join(\"\\n\") + \"\\n\";\n await post(\"Sentry\", endpoint, {\n headers: {\n \"content-type\": \"application/x-sentry-envelope\",\n \"x-sentry-auth\": `Sentry sentry_version=7, sentry_key=${publicKey}, sentry_client=cronwatch`,\n },\n body: envelope,\n }, [publicKey]);\n },\n };\n}\n","// @cronwatch/sdk/sentry: what the entry point exports. parseDsn is internal\n// to the channel; it is kept, deprecated, and leaves the exports in 1.0.\nimport * as internal from \"../alerts/sentry.js\";\n\nexport { sentry } from \"../alerts/sentry.js\";\nexport type { SentryOptions } from \"../alerts/sentry.js\";\n\n/** @deprecated Internal to the Sentry channel; leaves the exports in 1.0. */\nexport const parseDsn = internal.parseDsn;\n"]}
package/dist/sentry.js CHANGED
@@ -20,18 +20,33 @@ function origin(url) {
20
20
  }
21
21
  }
22
22
  function postable(url) {
23
- let protocol;
23
+ let parsed;
24
24
  try {
25
- protocol = new URL(url).protocol;
25
+ parsed = new URL(url);
26
26
  } catch {
27
27
  throw new Error("only http and https URLs can be posted to, not this URL");
28
28
  }
29
- if (protocol !== "http:" && protocol !== "https:") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);
29
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`only http and https URLs can be posted to, not ${parsed.protocol}`);
30
+ if (parsed.username || parsed.password) throw new Error("URLs containing credentials cannot be posted to");
30
31
  return url;
31
32
  }
33
+ async function fetchPost(url, init) {
34
+ const target = postable(url);
35
+ try {
36
+ new Headers(init.headers);
37
+ } catch {
38
+ throw new Error("request headers are not valid");
39
+ }
40
+ try {
41
+ return await fetch(target, { ...init, method: "POST", redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS) });
42
+ } catch (error) {
43
+ if (error?.name === "TimeoutError") throw new DOMException("The operation was aborted due to timeout", "TimeoutError");
44
+ throw new Error(`POST ${origin(target)} failed`);
45
+ }
46
+ }
32
47
  var ERROR_BODY_MAX = 200;
33
48
  async function post(provider, url, init, secrets = []) {
34
- const response = await fetch(postable(url), { method: "POST", headers: init.headers, body: init.body, redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS) });
49
+ const response = await fetchPost(url, init);
35
50
  if (!response.ok) {
36
51
  const text = await errorText(response);
37
52
  throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : ""}`);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/duration.ts","../src/alerts/shared.ts","../src/alerts/sentry.ts","../src/entries/sentry.ts"],"names":["parseDsn"],"mappings":";AAsFO,IAAM,aAAA,GAAgB,YAAA;AAEtB,IAAM,YAAA,GAAe,eAAA;AAQrB,SAAS,QAAQ,EAAA,EAA2B;AACjD,EAAA,OAAO,EAAA,IAAM,iBAAiB,EAAA,IAAM,YAAA,GAAe,IAAI,IAAA,CAAK,EAAE,CAAA,CAAE,WAAA,EAAY,GAAI,IAAA;AAClF;;;AC1FO,IAAM,UAAA,GAAa,GAAA;AAKnB,SAAS,SAAS,IAAA,EAA+B;AACtD,EAAA,IAAI,IAAA,KAAS,aAAa,OAAO,MAAA;AACjC,EAAA,IAAI,SAAS,MAAA,IAAU,IAAA,KAAS,aAAA,IAAiB,IAAA,KAAS,eAAe,OAAO,SAAA;AAChF,EAAA,OAAO,OAAA;AACT;AAGO,SAAS,OAAO,GAAA,EAAqB;AAC1C,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,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;AAGO,IAAM,cAAA,GAAiB,GAAA;AAW9B,eAAsB,KAAK,QAAA,EAAkB,GAAA,EAAa,IAAA,EAAyD,OAAA,GAAkC,EAAC,EAAsB;AAC1K,EAAA,MAAM,QAAA,GAAW,MAAM,KAAA,CAAM,QAAA,CAAS,GAAG,CAAA,EAAG,EAAE,MAAA,EAAQ,MAAA,EAAQ,OAAA,EAAS,IAAA,CAAK,SAAS,IAAA,EAAM,IAAA,CAAK,MAAM,QAAA,EAAU,OAAA,EAAS,QAAQ,WAAA,CAAY,OAAA,CAAQ,UAAU,CAAA,EAAG,CAAA;AAClK,EAAA,IAAI,CAAC,SAAS,EAAA,EAAI;AAChB,IAAA,MAAM,IAAA,GAAO,MAAM,SAAA,CAAU,QAAQ,CAAA;AACrC,IAAA,MAAM,IAAI,MAAM,CAAA,EAAG,QAAQ,IAAI,MAAA,CAAO,GAAG,CAAC,CAAA,UAAA,EAAa,QAAA,CAAS,MAAM,CAAA,EAAG,IAAA,GAAO,KAAK,SAAA,CAAU,IAAA,EAAM,OAAO,CAAC,CAAA,CAAA,GAAK,EAAE,CAAA,CAAE,CAAA;AAAA,EACxH;AACA,EAAA,MAAM,MAAM,QAAQ,CAAA;AACpB,EAAA,OAAO,QAAA;AACT;AAGO,IAAM,iBAAiB,EAAA,GAAK,IAAA;AASnC,eAAsB,UAAU,QAAA,EAAqC;AACnE,EAAA,MAAM,MAAA,GAAS,QAAA,CAAS,IAAA,EAAM,SAAA,EAAU;AACxC,EAAA,IAAI,CAAC,QAAQ,OAAO,EAAA;AACpB,EAAA,MAAM,SAAuB,EAAC;AAC9B,EAAA,IAAI,IAAA,GAAO,CAAA;AACX,EAAA,IAAI;AACF,IAAA,OAAO,OAAO,cAAA,EAAgB;AAC5B,MAAA,MAAM,EAAE,IAAA,EAAM,KAAA,EAAM,GAAI,MAAM,OAAO,IAAA,EAAK;AAC1C,MAAA,IAAI,IAAA,EAAM;AACV,MAAA,MAAM,IAAA,GAAO,KAAA,CAAM,QAAA,CAAS,CAAA,EAAG,iBAAiB,IAAI,CAAA;AACpD,MAAA,MAAA,CAAO,KAAK,IAAI,CAAA;AAChB,MAAA,IAAA,IAAQ,IAAA,CAAK,UAAA;AAAA,IACf;AAAA,EACF,CAAA,CAAA,MAAQ;AAAA,EAER,CAAA,SAAE;AACA,IAAA,MAAM,MAAA,CAAO,MAAA,EAAO,CAAE,KAAA,CAAM,MAAM;AAAA,IAAC,CAAC,CAAA;AAAA,EACtC;AACA,EAAA,MAAM,KAAA,GAAQ,IAAI,UAAA,CAAW,IAAI,CAAA;AACjC,EAAA,IAAI,EAAA,GAAK,CAAA;AACT,EAAA,KAAA,MAAW,SAAS,MAAA,EAAQ;AAC1B,IAAA,KAAA,CAAM,GAAA,CAAI,OAAO,EAAE,CAAA;AACnB,IAAA,EAAA,IAAM,KAAA,CAAM,UAAA;AAAA,EACd;AACA,EAAA,OAAO,IAAI,WAAA,EAAY,CAAE,MAAA,CAAO,KAAK,CAAA;AACvC;AAMA,eAAsB,MAAM,QAAA,EAAmC;AAC7D,EAAA,IAAI;AACF,IAAA,MAAM,QAAA,CAAS,MAAM,MAAA,EAAO;AAAA,EAC9B,CAAA,CAAA,MAAQ;AAAA,EAER;AACF;AAkBO,SAAS,SAAA,CAAU,IAAA,EAAc,OAAA,GAAkC,EAAC,EAAW;AACpF,EAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,MAAA,CAAO,CAAC,CAAA,KAAmB,OAAO,CAAA,KAAM,QAAA,IAAY,CAAA,CAAE,MAAA,IAAU,CAAC,CAAA;AACtF,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,MAAA,CAAO,CAAC,CAAA,EAAG,CAAA,KAAM,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,CAAA,CAAE,MAAM,CAAA,EAAG,CAAC,CAAA;AAC9D,EAAA,IAAI,IAAA,GAAO,GAAA,CAAI,IAAA,EAAM,cAAA,GAAiB,OAAO,CAAA;AAC7C,EAAA,KAAA,MAAW,MAAA,IAAU,MAAM,IAAA,GAAO,IAAA,CAAK,MAAM,MAAM,CAAA,CAAE,KAAK,YAAY,CAAA;AACtE,EAAA,OAAO,GAAA,CAAI,MAAM,cAAc,CAAA;AACjC;AAGO,SAAS,QAAQ,KAAA,EAAwB;AAC9C,EAAA,OAAO,OAAO,KAAA,KAAU,QAAA,GAAW,KAAA,CAAM,MAAK,GAAI,EAAA;AACpD;AAaO,SAAS,IAAI,KAAA,EAAyC;AAC3D,EAAA,OAAO,KAAA,CAAM,KAAK,KAAA,YAAiB,UAAA,GAAa,QAAQ,IAAI,UAAA,CAAW,KAAK,CAAA,EAAG,CAAC,MAAM,CAAA,CAAE,QAAA,CAAS,EAAE,CAAA,CAAE,QAAA,CAAS,GAAG,GAAG,CAAC,CAAA,CAAE,IAAA,CAAK,EAAE,CAAA;AAChI;AAEA,eAAsB,UAAU,IAAA,EAA+B;AAC7D,EAAA,OAAO,GAAA,CAAI,MAAM,MAAA,CAAO,MAAA,CAAO,MAAA,CAAO,SAAA,EAAW,IAAI,WAAA,EAAY,CAAE,MAAA,CAAO,IAAI,CAAC,CAAC,CAAA;AAClF;AAOA,eAAsB,QAAQ,KAAA,EAA+B;AAC3D,EAAA,OAAA,CAAQ,MAAM,SAAA,CAAU,CAAA,EAAG,KAAA,CAAM,GAAG;AAAA,EAAK,MAAM,IAAI;AAAA,EAAK,MAAM,EAAE,CAAA,CAAE,CAAA,EAAG,KAAA,CAAM,GAAG,EAAE,CAAA;AAClF;AAQO,SAAS,GAAA,CAAI,MAAc,GAAA,EAAqB;AACrD,EAAA,IAAI,IAAA,CAAK,MAAA,IAAU,GAAA,EAAK,OAAO,IAAA;AAC/B,EAAA,IAAI,GAAA,GAAM,GAAA;AACV,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,UAAA,CAAW,GAAA,GAAM,CAAC,CAAA;AACpC,EAAA,IAAI,IAAA,IAAQ,KAAA,IAAU,IAAA,IAAQ,KAAA,EAAQ,GAAA,IAAO,CAAA;AAC7C,EAAA,OAAO,IAAA,CAAK,KAAA,CAAM,CAAA,EAAG,GAAG,CAAA;AAC1B;AAGO,SAAS,WAAW,KAAA,EAA8C;AACvE,EAAA,MAAM,MAAM,KAAA,CAAM,GAAA;AAClB,EAAA,IAAI,CAAC,KAAK,OAAO,IAAA;AACjB,EAAA,OAAO,EAAE,EAAA,EAAI,GAAA,CAAI,EAAA,EAAI,MAAA,EAAQ,IAAI,MAAA,EAAQ,SAAA,EAAW,OAAA,CAAQ,GAAA,CAAI,SAAS,CAAA,EAAG,UAAA,EAAY,IAAI,UAAA,EAAY,OAAA,EAAS,IAAI,OAAA,EAAQ;AAC/H;;;ACtKO,SAAS,SAAS,GAAA,EAAkB;AACzC,EAAA,IAAI,GAAA;AACJ,EAAA,IAAI;AACF,IAAA,GAAA,GAAM,IAAI,IAAI,GAAG,CAAA;AAAA,EACnB,CAAA,CAAA,MAAQ;AACN,IAAA,MAAM,IAAI,MAAM,4BAA4B,CAAA;AAAA,EAC9C;AACA,EAAA,MAAM,WAAW,GAAA,CAAI,QAAA,CAAS,MAAM,GAAG,CAAA,CAAE,OAAO,OAAO,CAAA;AACvD,EAAA,MAAM,OAAA,GAAU,SAAS,GAAA,EAAI;AAC7B,EAAA,IAAI,CAAC,GAAA,CAAI,QAAA,IAAY,CAAC,OAAA,IAAW,CAAC,OAAA,CAAQ,IAAA,CAAK,OAAO,CAAA,EAAG,MAAM,IAAI,MAAM,0DAA0D,CAAA;AACnI,EAAA,MAAM,MAAA,GAAS,SAAS,MAAA,GAAS,CAAA,CAAA,EAAI,SAAS,IAAA,CAAK,GAAG,CAAC,CAAA,CAAA,GAAK,EAAA;AAC5D,EAAA,OAAO,EAAE,QAAA,EAAU,CAAA,EAAG,GAAA,CAAI,QAAQ,KAAK,GAAA,CAAI,IAAI,CAAA,EAAG,MAAM,QAAQ,OAAO,CAAA,UAAA,CAAA,EAAc,WAAW,kBAAA,CAAmB,GAAA,CAAI,QAAQ,CAAA,EAAE;AACnI;AAGO,SAAS,OAAO,OAAA,EAAsC;AAE3D,EAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,OAAA,CAAQ,GAAG,CAAA;AAC/B,EAAA,IAAI,CAAC,GAAA,EAAK,MAAM,IAAI,MAAM,sBAAsB,CAAA;AAChD,EAAA,MAAM,EAAE,QAAA,EAAU,SAAA,EAAU,GAAI,SAAS,GAAG,CAAA;AAC5C,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,QAAA;AAAA,IACN,MAAM,KAAK,KAAA,EAAO;AAChB,MAAA,IAAI,KAAA,CAAM,IAAA,KAAS,WAAA,IAAe,OAAA,CAAQ,cAAc,KAAA,EAAO;AAC/D,MAAA,MAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,KAAK,CAAA;AACnC,MAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,IAAA,GAAO,KAAK,CAAA;AACjC,MAAA,MAAM,KAAA,GAAQ;AAAA,QACZ,QAAA,EAAU,OAAA;AAAA,QACV,SAAA,EAAW,MAAM,EAAA,GAAK,GAAA;AAAA,QACtB,QAAA,EAAU,OAAA;AAAA,QACV,KAAA,EAAO,QAAA,CAAS,KAAA,CAAM,IAAI,CAAA;AAAA,QAC1B,MAAA,EAAQ,WAAA;AAAA,QACR,aAAa,KAAA,CAAM,GAAA;AAAA,QACnB,WAAA,EAAa,QAAQ,WAAA,IAAe,YAAA;AAAA,QACpC,GAAI,QAAQ,OAAA,GAAU,EAAE,SAAS,OAAA,CAAQ,OAAA,KAAY,EAAC;AAAA;AAAA,QAEtD,UAAU,EAAE,SAAA,EAAW,GAAA,CAAI,CAAA,EAAG,MAAM,KAAK;;AAAA,EAAO,KAAA,CAAM,OAAO,CAAA,CAAA,EAAI,IAAI,CAAA,EAAE;AAAA,QACvE,aAAa,CAAC,WAAA,EAAa,KAAA,CAAM,GAAA,EAAK,MAAM,IAAI,CAAA;AAAA,QAChD,IAAA,EAAM,EAAE,GAAA,EAAK,GAAA,CAAI,KAAA,CAAM,KAAK,GAAG,CAAA,EAAG,IAAA,EAAM,KAAA,CAAM,IAAA,EAAK;AAAA,QACnD,KAAA,EAAO;AAAA,UACL,GAAI,MAAM,MAAA,GAAS,EAAE,QAAQ,KAAA,CAAM,MAAA,KAAW,EAAC;AAAA,UAC/C,GAAI,IAAA,GAAO,EAAE,IAAA,KAAS,EAAC;AAAA,UACvB,SAAS,KAAA,CAAM,OAAA;AAAA,UACf,GAAA,EAAK,WAAW,KAAK;AAAA;AACvB,OACF;AACA,MAAA,MAAM,OAAA,GAAU,IAAA,CAAK,SAAA,CAAU,KAAK,CAAA;AACpC,MAAA,MAAM,QAAA,GAAW;AAAA,QACf,IAAA,CAAK,SAAA,CAAU,EAAE,QAAA,EAAU,SAAS,CAAA;AAAA,QACpC,IAAA,CAAK,SAAA,CAAU,EAAE,IAAA,EAAM,SAAS,YAAA,EAAc,kBAAA,EAAoB,MAAA,EAAQ,IAAI,aAAY,CAAE,MAAA,CAAO,OAAO,CAAA,CAAE,QAAQ,CAAA;AAAA,QACpH;AAAA,OACF,CAAE,IAAA,CAAK,IAAI,CAAA,GAAI,IAAA;AACf,MAAA,MAAM,IAAA,CAAK,UAAU,QAAA,EAAU;AAAA,QAC7B,OAAA,EAAS;AAAA,UACP,cAAA,EAAgB,+BAAA;AAAA,UAChB,eAAA,EAAiB,uCAAuC,SAAS,CAAA,yBAAA;AAAA,SACnE;AAAA,QACA,IAAA,EAAM;AAAA,OACR,EAAG,CAAC,SAAS,CAAC,CAAA;AAAA,IAChB;AAAA,GACF;AACF;;;AC9EO,IAAMA,SAAAA,GAAoB","file":"sentry.js","sourcesContent":["import type { Duration } from \"./types.js\";\n\nconst UNIT_MS: Record<string, number> = {\n ms: 1,\n s: 1000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n w: 604_800_000,\n};\n\n/**\n * The longest duration string read, in characters (code points). No real\n * duration comes near it, and the pattern below is quadratic on a long run of\n * digits, so a longer string is refused before it is read.\n */\nconst MAX_LENGTH = 64;\n/** How much of a refused, overlong string its error quotes. */\nconst QUOTED = 32;\n\n/**\n * \"15m\" -> 900000. Accepts a plain number of milliseconds, and compound\n * strings such as \"1h30m\". Whitespace between parts is fine.\n */\nexport function parseDuration(value: Duration, label = \"duration\"): number {\n if (typeof value === \"number\") {\n if (!Number.isFinite(value) || value < 0) throw new Error(`${label} must be a non-negative number of milliseconds`);\n return value;\n }\n if (value.length > MAX_LENGTH) tooLong(value, label);\n const text = value.trim().toLowerCase();\n if (text === \"\") throw new Error(`${label} is empty`);\n const re = /(\\d+(?:\\.\\d+)?)\\s*(ms|s|m|h|d|w)/g;\n let total = 0;\n let consumed = \"\";\n let match: RegExpExecArray | null;\n while ((match = re.exec(text)) !== null) {\n total += parseFloat(match[1]!) * UNIT_MS[match[2]!]!;\n consumed += match[0];\n }\n if (consumed.replace(/\\s+/g, \"\") !== text.replace(/\\s+/g, \"\")) {\n throw new Error(`${label} \"${value}\" is not a duration like \"15m\", \"1h30m\", or \"90s\"`);\n }\n return Math.round(total);\n}\n\n/** Throws if `value` is over MAX_LENGTH characters, quoting the first QUOTED. */\nfunction tooLong(value: string, label: string): void {\n let count = 0;\n let head = \"\";\n for (const char of value) {\n if (count < QUOTED) head += char;\n if (++count > MAX_LENGTH) {\n throw new Error(`${label} \"${head}...\" is too long for a duration (more than ${MAX_LENGTH} characters)`);\n }\n }\n}\n\n/** 90000 -> \"1m 30s\". For messages, not for parsing back. */\nexport function formatDuration(ms: number): string {\n if (!Number.isFinite(ms)) return \"?\";\n if (ms < 1000) return `${Math.round(ms)}ms`;\n const parts: string[] = [];\n let rest = Math.round(ms / 1000);\n const units: [string, number][] = [[\"d\", 86_400], [\"h\", 3_600], [\"m\", 60], [\"s\", 1]];\n for (const [unit, size] of units) {\n if (rest >= size) {\n const n = Math.floor(rest / size);\n rest -= n * size;\n parts.push(`${n}${unit}`);\n }\n if (parts.length === 2) break;\n }\n return parts.join(\" \") || \"0s\";\n}\n\n/** \"5 minutes ago\", \"in 2h\". Relative to `now`. */\nexport function formatRelative(at: number, now: number): string {\n const diff = at - now;\n const abs = Math.abs(diff);\n const text = abs < 5_000 ? \"now\" : formatDuration(abs);\n if (abs < 5_000) return text;\n return diff < 0 ? `${text} ago` : `in ${text}`;\n}\n\n/** The first millisecond written as a date: 0001-01-01T00:00:00.000Z. */\nexport const FIRST_DATE_MS = -62_135_596_800_000;\n/** The last millisecond written as a date: 9999-12-31T23:59:59.999Z. */\nexport const LAST_DATE_MS = 253_402_300_799_999;\n\n/**\n * \"2026-01-05T09:30:00.000Z\", or null for a time before the year 1 or after\n * 9999. A start read from another process's row, or a damaged one, can be\n * any number; outside those years it is not written as a date at all (and\n * past JavaScript's Date range, toISOString would throw).\n */\nexport function isoTime(at: number): string | null {\n return at >= FIRST_DATE_MS && at <= LAST_DATE_MS ? new Date(at).toISOString() : null;\n}\n\n/** The words that stand in for a time isoTime() does not write: \"before 0001-01-01 00:00:00 UTC\" or \"after 9999-12-31 23:59:59 UTC\". */\nexport function beyondDates(at: number): string {\n return at > LAST_DATE_MS ? \"after 9999-12-31 23:59:59 UTC\" : \"before 0001-01-01 00:00:00 UTC\";\n}\n","/**\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\" || type === \"under_floor\") 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 const text = await errorText(response);\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n await drain(response);\n return response;\n}\n\n/** The most of an error body read (64 KiB), far more than any secret plus ERROR_BODY_MAX needs. */\nexport const ERROR_READ_MAX = 64 * 1024;\n\n/**\n * The start of an error answer's body, at most ERROR_READ_MAX bytes read\n * from the stream and the rest let go, so a provider (or something posing\n * as one) cannot make a send hold a body of any size. \"\" when it cannot be\n * read. A character cut at the end decodes as U+FFFD, far past what\n * errorBody keeps.\n */\nexport async function errorText(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n const chunks: Uint8Array[] = [];\n let size = 0;\n try {\n while (size < ERROR_READ_MAX) {\n const { done, value } = await reader.read();\n if (done) break;\n const take = value.subarray(0, ERROR_READ_MAX - size);\n chunks.push(take);\n size += take.byteLength;\n }\n } catch {\n // The status is enough; whatever was read is kept.\n } finally {\n await reader.cancel().catch(() => {});\n }\n const bytes = new Uint8Array(size);\n let at = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, at);\n at += chunk.byteLength;\n }\n return new TextDecoder().decode(bytes);\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\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 * Sentry, through the envelope endpoint.\n * Envelopes: https://develop.sentry.dev/sdk/data-model/envelopes/\n * Event payload: https://develop.sentry.dev/sdk/data-model/event-payloads/\n * DSN and X-Sentry-Auth: https://develop.sentry.dev/sdk/foundations/transport/authentication/\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { alertId, cut, post, runSummary, severity, trimmed } from \"./shared.js\";\n\nexport interface SentryOptions {\n /** The project's DSN, \"https://<key>@o0.ingest.sentry.io/<project>\". */\n dsn: string;\n /** Defaults to \"production\". */\n environment?: string;\n release?: string;\n /** Also send recoveries, as info events. Defaults to true. */\n recovered?: boolean;\n link?: (alert: Alert) => string;\n}\n\ninterface Dsn {\n endpoint: string;\n publicKey: string;\n}\n\nexport function parseDsn(dsn: string): Dsn {\n let url: URL;\n try {\n url = new URL(dsn);\n } catch {\n throw new Error(\"sentry() needs a valid dsn\");\n }\n const segments = url.pathname.split(\"/\").filter(Boolean);\n const project = segments.pop();\n if (!url.username || !project || !/^\\d+$/.test(project)) throw new Error(\"sentry() needs a dsn like https://<key>@<host>/<project>\");\n const prefix = segments.length ? `/${segments.join(\"/\")}` : \"\";\n return { endpoint: `${url.protocol}//${url.host}${prefix}/api/${project}/envelope/`, publicKey: decodeURIComponent(url.username) };\n}\n\n/** Sends alerts to Sentry as events, one issue per job and alert type. */\nexport function sentry(options: SentryOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const dsn = trimmed(options.dsn);\n if (!dsn) throw new Error(\"sentry() needs a dsn\");\n const { endpoint, publicKey } = parseDsn(dsn);\n return {\n name: \"sentry\",\n async send(alert) {\n if (alert.type === \"recovered\" && options.recovered === false) return;\n const eventId = await alertId(alert);\n const link = options.link?.(alert);\n const event = {\n event_id: eventId,\n timestamp: alert.at / 1000,\n platform: \"other\",\n level: severity(alert.type),\n logger: \"cronwatch\",\n transaction: alert.job,\n environment: options.environment ?? \"production\",\n ...(options.release ? { release: options.release } : {}),\n // The first line is the issue title.\n logentry: { formatted: cut(`${alert.title}\\n\\n${alert.message}`, 8192) },\n fingerprint: [\"cronwatch\", alert.job, alert.type],\n tags: { job: cut(alert.job, 199), type: alert.type },\n extra: {\n ...(alert.triage ? { triage: alert.triage } : {}),\n ...(link ? { link } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n };\n const payload = JSON.stringify(event);\n const envelope = [\n JSON.stringify({ event_id: eventId }),\n JSON.stringify({ type: \"event\", content_type: \"application/json\", length: new TextEncoder().encode(payload).length }),\n payload,\n ].join(\"\\n\") + \"\\n\";\n await post(\"Sentry\", endpoint, {\n headers: {\n \"content-type\": \"application/x-sentry-envelope\",\n \"x-sentry-auth\": `Sentry sentry_version=7, sentry_key=${publicKey}, sentry_client=cronwatch`,\n },\n body: envelope,\n }, [publicKey]);\n },\n };\n}\n","// @cronwatch/sdk/sentry: what the entry point exports. parseDsn is internal\n// to the channel; it is kept, deprecated, and leaves the exports in 1.0.\nimport * as internal from \"../alerts/sentry.js\";\n\nexport { sentry } from \"../alerts/sentry.js\";\nexport type { SentryOptions } from \"../alerts/sentry.js\";\n\n/** @deprecated Internal to the Sentry channel; leaves the exports in 1.0. */\nexport const parseDsn = internal.parseDsn;\n"]}
1
+ {"version":3,"sources":["../src/duration.ts","../src/alerts/shared.ts","../src/alerts/sentry.ts","../src/entries/sentry.ts"],"names":["parseDsn"],"mappings":"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","file":"sentry.js","sourcesContent":["import type { Duration } from \"./types.js\";\n\nconst UNIT_MS: Record<string, number> = {\n ms: 1,\n s: 1000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n w: 604_800_000,\n};\n\n/**\n * The longest duration string read, in characters (code points). No real\n * duration comes near it, and the pattern below is quadratic on a long run of\n * digits, so a longer string is refused before it is read.\n */\nconst MAX_LENGTH = 64;\n/** How much of a refused, overlong string its error quotes. */\nconst QUOTED = 32;\n\n/**\n * \"15m\" -> 900000. Accepts a plain number of milliseconds, and compound\n * strings such as \"1h30m\". Whitespace between parts is fine.\n */\nexport function parseDuration(value: Duration, label = \"duration\"): number {\n if (typeof value === \"number\") {\n if (!Number.isFinite(value) || value < 0) throw new Error(`${label} must be a non-negative number of milliseconds`);\n return value;\n }\n if (value.length > MAX_LENGTH) tooLong(value, label);\n const text = value.trim().toLowerCase();\n if (text === \"\") throw new Error(`${label} is empty`);\n const re = /(\\d+(?:\\.\\d+)?)\\s*(ms|s|m|h|d|w)/g;\n let total = 0;\n let consumed = \"\";\n let match: RegExpExecArray | null;\n while ((match = re.exec(text)) !== null) {\n total += parseFloat(match[1]!) * UNIT_MS[match[2]!]!;\n consumed += match[0];\n }\n if (consumed.replace(/\\s+/g, \"\") !== text.replace(/\\s+/g, \"\")) {\n throw new Error(`${label} \"${value}\" is not a duration like \"15m\", \"1h30m\", or \"90s\"`);\n }\n return Math.round(total);\n}\n\n/** Throws if `value` is over MAX_LENGTH characters, quoting the first QUOTED. */\nfunction tooLong(value: string, label: string): void {\n let count = 0;\n let head = \"\";\n for (const char of value) {\n if (count < QUOTED) head += char;\n if (++count > MAX_LENGTH) {\n throw new Error(`${label} \"${head}...\" is too long for a duration (more than ${MAX_LENGTH} characters)`);\n }\n }\n}\n\n/** 90000 -> \"1m 30s\". For messages, not for parsing back. */\nexport function formatDuration(ms: number): string {\n if (!Number.isFinite(ms)) return \"?\";\n if (ms < 1000) return `${Math.round(ms)}ms`;\n const parts: string[] = [];\n let rest = Math.round(ms / 1000);\n const units: [string, number][] = [[\"d\", 86_400], [\"h\", 3_600], [\"m\", 60], [\"s\", 1]];\n for (const [unit, size] of units) {\n if (rest >= size) {\n const n = Math.floor(rest / size);\n rest -= n * size;\n parts.push(`${n}${unit}`);\n }\n if (parts.length === 2) break;\n }\n return parts.join(\" \") || \"0s\";\n}\n\n/** \"5 minutes ago\", \"in 2h\". Relative to `now`. */\nexport function formatRelative(at: number, now: number): string {\n const diff = at - now;\n const abs = Math.abs(diff);\n const text = abs < 5_000 ? \"now\" : formatDuration(abs);\n if (abs < 5_000) return text;\n return diff < 0 ? `${text} ago` : `in ${text}`;\n}\n\n/** The first millisecond written as a date: 0001-01-01T00:00:00.000Z. */\nexport const FIRST_DATE_MS = -62_135_596_800_000;\n/** The last millisecond written as a date: 9999-12-31T23:59:59.999Z. */\nexport const LAST_DATE_MS = 253_402_300_799_999;\n\n/**\n * \"2026-01-05T09:30:00.000Z\", or null for a time before the year 1 or after\n * 9999. A start read from another process's row, or a damaged one, can be\n * any number; outside those years it is not written as a date at all (and\n * past JavaScript's Date range, toISOString would throw).\n */\nexport function isoTime(at: number): string | null {\n return at >= FIRST_DATE_MS && at <= LAST_DATE_MS ? new Date(at).toISOString() : null;\n}\n\n/** The words that stand in for a time isoTime() does not write: \"before 0001-01-01 00:00:00 UTC\" or \"after 9999-12-31 23:59:59 UTC\". */\nexport function beyondDates(at: number): string {\n return at > LAST_DATE_MS ? \"after 9999-12-31 23:59:59 UTC\" : \"before 0001-01-01 00:00:00 UTC\";\n}\n","/**\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\" || type === \"under_floor\") 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 parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${parsed.protocol}`);\n if (parsed.username || parsed.password) throw new Error(\"URLs containing credentials cannot be posted to\");\n return url;\n}\n\n/** Fetch can quote credentials in construction errors. Only safe diagnostics leave this boundary. */\nexport async function fetchPost(url: string, init: { headers: Record<string, string>; body: string }): Promise<Response> {\n const target = postable(url);\n try {\n new Headers(init.headers);\n } catch {\n throw new Error(\"request headers are not valid\");\n }\n try {\n return await fetch(target, { ...init, method: \"POST\", redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n } catch (error) {\n if ((error as Error)?.name === \"TimeoutError\") throw new DOMException(\"The operation was aborted due to timeout\", \"TimeoutError\");\n throw new Error(`POST ${origin(target)} failed`);\n }\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 fetchPost(url, init);\n if (!response.ok) {\n const text = await errorText(response);\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n await drain(response);\n return response;\n}\n\n/** The most of an error body read (64 KiB), far more than any secret plus ERROR_BODY_MAX needs. */\nexport const ERROR_READ_MAX = 64 * 1024;\n\n/**\n * The start of an error answer's body, at most ERROR_READ_MAX bytes read\n * from the stream and the rest let go, so a provider (or something posing\n * as one) cannot make a send hold a body of any size. \"\" when it cannot be\n * read. A character cut at the end decodes as U+FFFD, far past what\n * errorBody keeps.\n */\nexport async function errorText(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n const chunks: Uint8Array[] = [];\n let size = 0;\n try {\n while (size < ERROR_READ_MAX) {\n const { done, value } = await reader.read();\n if (done) break;\n const take = value.subarray(0, ERROR_READ_MAX - size);\n chunks.push(take);\n size += take.byteLength;\n }\n } catch {\n // The status is enough; whatever was read is kept.\n } finally {\n await reader.cancel().catch(() => {});\n }\n const bytes = new Uint8Array(size);\n let at = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, at);\n at += chunk.byteLength;\n }\n return new TextDecoder().decode(bytes);\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\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 * Sentry, through the envelope endpoint.\n * Envelopes: https://develop.sentry.dev/sdk/data-model/envelopes/\n * Event payload: https://develop.sentry.dev/sdk/data-model/event-payloads/\n * DSN and X-Sentry-Auth: https://develop.sentry.dev/sdk/foundations/transport/authentication/\n */\nimport type { Alert, AlertChannel } from \"../types.js\";\nimport { alertId, cut, post, runSummary, severity, trimmed } from \"./shared.js\";\n\nexport interface SentryOptions {\n /** The project's DSN, \"https://<key>@o0.ingest.sentry.io/<project>\". */\n dsn: string;\n /** Defaults to \"production\". */\n environment?: string;\n release?: string;\n /** Also send recoveries, as info events. Defaults to true. */\n recovered?: boolean;\n link?: (alert: Alert) => string;\n}\n\ninterface Dsn {\n endpoint: string;\n publicKey: string;\n}\n\nexport function parseDsn(dsn: string): Dsn {\n let url: URL;\n try {\n url = new URL(dsn);\n } catch {\n throw new Error(\"sentry() needs a valid dsn\");\n }\n const segments = url.pathname.split(\"/\").filter(Boolean);\n const project = segments.pop();\n if (!url.username || !project || !/^\\d+$/.test(project)) throw new Error(\"sentry() needs a dsn like https://<key>@<host>/<project>\");\n const prefix = segments.length ? `/${segments.join(\"/\")}` : \"\";\n return { endpoint: `${url.protocol}//${url.host}${prefix}/api/${project}/envelope/`, publicKey: decodeURIComponent(url.username) };\n}\n\n/** Sends alerts to Sentry as events, one issue per job and alert type. */\nexport function sentry(options: SentryOptions): AlertChannel {\n // A pasted credential often carries a stray space or newline, which a header would refuse or send.\n const dsn = trimmed(options.dsn);\n if (!dsn) throw new Error(\"sentry() needs a dsn\");\n const { endpoint, publicKey } = parseDsn(dsn);\n return {\n name: \"sentry\",\n async send(alert) {\n if (alert.type === \"recovered\" && options.recovered === false) return;\n const eventId = await alertId(alert);\n const link = options.link?.(alert);\n const event = {\n event_id: eventId,\n timestamp: alert.at / 1000,\n platform: \"other\",\n level: severity(alert.type),\n logger: \"cronwatch\",\n transaction: alert.job,\n environment: options.environment ?? \"production\",\n ...(options.release ? { release: options.release } : {}),\n // The first line is the issue title.\n logentry: { formatted: cut(`${alert.title}\\n\\n${alert.message}`, 8192) },\n fingerprint: [\"cronwatch\", alert.job, alert.type],\n tags: { job: cut(alert.job, 199), type: alert.type },\n extra: {\n ...(alert.triage ? { triage: alert.triage } : {}),\n ...(link ? { link } : {}),\n details: alert.details,\n run: runSummary(alert),\n },\n };\n const payload = JSON.stringify(event);\n const envelope = [\n JSON.stringify({ event_id: eventId }),\n JSON.stringify({ type: \"event\", content_type: \"application/json\", length: new TextEncoder().encode(payload).length }),\n payload,\n ].join(\"\\n\") + \"\\n\";\n await post(\"Sentry\", endpoint, {\n headers: {\n \"content-type\": \"application/x-sentry-envelope\",\n \"x-sentry-auth\": `Sentry sentry_version=7, sentry_key=${publicKey}, sentry_client=cronwatch`,\n },\n body: envelope,\n }, [publicKey]);\n },\n };\n}\n","// @cronwatch/sdk/sentry: what the entry point exports. parseDsn is internal\n// to the channel; it is kept, deprecated, and leaves the exports in 1.0.\nimport * as internal from \"../alerts/sentry.js\";\n\nexport { sentry } from \"../alerts/sentry.js\";\nexport type { SentryOptions } from \"../alerts/sentry.js\";\n\n/** @deprecated Internal to the Sentry channel; leaves the exports in 1.0. */\nexport const parseDsn = internal.parseDsn;\n"]}
package/dist/ses.cjs CHANGED
@@ -10,18 +10,33 @@ function origin(url) {
10
10
  }
11
11
  }
12
12
  function postable(url) {
13
- let protocol;
13
+ let parsed;
14
14
  try {
15
- protocol = new URL(url).protocol;
15
+ parsed = new URL(url);
16
16
  } catch {
17
17
  throw new Error("only http and https URLs can be posted to, not this URL");
18
18
  }
19
- if (protocol !== "http:" && protocol !== "https:") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);
19
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`only http and https URLs can be posted to, not ${parsed.protocol}`);
20
+ if (parsed.username || parsed.password) throw new Error("URLs containing credentials cannot be posted to");
20
21
  return url;
21
22
  }
23
+ async function fetchPost(url, init) {
24
+ const target = postable(url);
25
+ try {
26
+ new Headers(init.headers);
27
+ } catch {
28
+ throw new Error("request headers are not valid");
29
+ }
30
+ try {
31
+ return await fetch(target, { ...init, method: "POST", redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS) });
32
+ } catch (error) {
33
+ if (error?.name === "TimeoutError") throw new DOMException("The operation was aborted due to timeout", "TimeoutError");
34
+ throw new Error(`POST ${origin(target)} failed`);
35
+ }
36
+ }
22
37
  var ERROR_BODY_MAX = 200;
23
38
  async function post(provider, url, init, secrets = []) {
24
- const response = await fetch(postable(url), { method: "POST", headers: init.headers, body: init.body, redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS) });
39
+ const response = await fetchPost(url, init);
25
40
  if (!response.ok) {
26
41
  const text = await errorText(response);
27
42
  throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : ""}`);
package/dist/ses.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/email.ts","../src/alerts/sigv4.ts","../src/alerts/ses.ts"],"names":[],"mappings":"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","file":"ses.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\" || type === \"under_floor\") 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 const text = await errorText(response);\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n await drain(response);\n return response;\n}\n\n/** The most of an error body read (64 KiB), far more than any secret plus ERROR_BODY_MAX needs. */\nexport const ERROR_READ_MAX = 64 * 1024;\n\n/**\n * The start of an error answer's body, at most ERROR_READ_MAX bytes read\n * from the stream and the rest let go, so a provider (or something posing\n * as one) cannot make a send hold a body of any size. \"\" when it cannot be\n * read. A character cut at the end decodes as U+FFFD, far past what\n * errorBody keeps.\n */\nexport async function errorText(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n const chunks: Uint8Array[] = [];\n let size = 0;\n try {\n while (size < ERROR_READ_MAX) {\n const { done, value } = await reader.read();\n if (done) break;\n const take = value.subarray(0, ERROR_READ_MAX - size);\n chunks.push(take);\n size += take.byteLength;\n }\n } catch {\n // The status is enough; whatever was read is kept.\n } finally {\n await reader.cancel().catch(() => {});\n }\n const bytes = new Uint8Array(size);\n let at = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, at);\n at += chunk.byteLength;\n }\n return new TextDecoder().decode(bytes);\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\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 * What every email channel sends: one subject, a plain text body, and a small\n * HTML body, so an alert reads the same whichever provider carries it. Not an\n * entry point; resend, postmark, sendgrid, mailgun, and ses each bundle it.\n */\nimport type { Alert } from \"../types.js\";\nimport { cut, plainText } from \"./shared.js\";\n\n/** The options every email channel takes. */\nexport interface EmailOptions {\n /** The sender, \"alerts@example.com\" or \"CronWatch <alerts@example.com>\". The provider must allow it. */\n from: string;\n /** One address or several. */\n to: string | string[];\n /** Put in front of the title in the subject, \"[prod]\" say. */\n subjectPrefix?: string;\n /** Link back to the job in your dashboard: (alert) => `https://app.example.com/cronwatch/jobs/${encodeURIComponent(alert.job)}`. */\n link?: (alert: Alert) => string;\n}\n\nexport interface Email {\n from: string;\n to: string[];\n subject: string;\n text: string;\n html: string;\n}\n\n/** Checks the shared options once, when the channel is made. */\nexport function recipients(name: string, options: EmailOptions): string[] {\n if (!options.from) throw new Error(`${name}() needs a from address`);\n const to = (Array.isArray(options.to) ? options.to : [options.to]).filter((a) => typeof a === \"string\" && a.trim() !== \"\");\n if (to.length === 0) throw new Error(`${name}() needs at least one to address`);\n return to.map((a) => a.trim());\n}\n\nexport function composeEmail(alert: Alert, options: EmailOptions, to: string[]): Email {\n const link = safeLink(options.link?.(alert));\n // One line: a newline in a subject is a header injection or a rejected send.\n const subject = cut(`${options.subjectPrefix ? `${options.subjectPrefix} ` : \"\"}${alert.title}`.replace(/[\\r\\n]+/g, \" \"), 250);\n return { from: options.from, to, subject, text: plainText(alert, link), html: html(alert, link) };\n}\n\n/** Escapes text for HTML content and double quoted attributes. */\nexport function escapeHtml(text: string): string {\n return text.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\").replace(/\"/g, \"&quot;\").replace(/'/g, \"&#39;\");\n}\n\n/** Only http and https links are put in a mail; anything else is dropped. */\nfunction safeLink(link: string | undefined): string | undefined {\n if (!link) return undefined;\n return /^https?:\\/\\//i.test(link) ? link : undefined;\n}\n\nfunction html(alert: Alert, link: string | undefined): string {\n const parts = [\n `<!doctype html>`,\n `<html><body style=\"margin:0;padding:16px;font-family:Georgia,serif;color:#1d1b16;background:#ffffff\">`,\n `<p style=\"margin:0 0 12px;font-size:18px\"><strong>${escapeHtml(alert.title)}</strong></p>`,\n `<pre style=\"margin:0 0 12px;padding:12px;background:#f6f3ec;white-space:pre-wrap;word-break:break-word;font:13px/1.45 Menlo,Consolas,monospace\">${escapeHtml(alert.message)}</pre>`,\n ];\n if (alert.triage) parts.push(`<p style=\"margin:0 0 12px\"><em>Triage:</em> ${escapeHtml(alert.triage)}</p>`);\n if (link) parts.push(`<p style=\"margin:0\"><a href=\"${escapeHtml(link)}\">Open ${escapeHtml(alert.job)}</a></p>`);\n parts.push(`</body></html>`);\n return parts.join(\"\\n\");\n}\n\n/** Splits \"Name <a@b.c>\" into its parts; a bare address has no name. */\nexport function parseAddress(address: string): { email: string; name?: string } {\n const match = /^\\s*(.*?)\\s*<([^<>]+)>\\s*$/.exec(address);\n if (!match) return { email: address.trim() };\n const name = match[1]!.replace(/^\"(.*)\"$/, \"$1\");\n return name ? { email: match[2]!.trim(), name } : { email: match[2]!.trim() };\n}\n","/**\n * AWS Signature Version 4 with Web Crypto, for the SES channel.\n * Spec: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html\n * Checked against the AWS SigV4 test suite (test/sigv4.test.ts).\n */\nimport { hex, sha256Hex } from \"./shared.js\";\n\nexport interface SigV4Credentials {\n accessKeyId: string;\n secretAccessKey: string;\n /** For temporary credentials (STS, an IAM role). Sent and signed as X-Amz-Security-Token. */\n sessionToken?: string;\n}\n\nexport interface SigV4Request {\n method: string;\n url: string;\n /** Headers to send and sign. Host is taken from the URL, and X-Amz-Date from `now`. */\n headers: Record<string, string>;\n body: string;\n region: string;\n service: string;\n /** Epoch milliseconds. */\n now: number;\n}\n\n/**\n * Returns the headers to send: the given ones plus x-amz-date, the session\n * token when there is one, and authorization. Host is signed but not\n * returned, because fetch sets it.\n */\nexport async function signV4(request: SigV4Request, credentials: SigV4Credentials): Promise<Record<string, string>> {\n const url = new URL(request.url);\n const amzDate = new Date(request.now).toISOString().replace(/[-:]/g, \"\").replace(/\\.\\d{3}/, \"\");\n const day = amzDate.slice(0, 8);\n const headers: Record<string, string> = {};\n for (const [name, value] of Object.entries(request.headers)) headers[name.toLowerCase()] = value;\n headers[\"x-amz-date\"] = amzDate;\n if (credentials.sessionToken) headers[\"x-amz-security-token\"] = credentials.sessionToken;\n\n const signed: Record<string, string> = { ...headers, host: url.host };\n const names = Object.keys(signed).sort();\n const canonicalHeaders = names.map((n) => `${n}:${signed[n]!.trim().replace(/\\s+/g, \" \")}\\n`).join(\"\");\n const signedHeaders = names.join(\";\");\n const canonicalRequest = [\n request.method.toUpperCase(),\n canonicalUri(url.pathname),\n canonicalQuery(url.searchParams),\n canonicalHeaders,\n signedHeaders,\n await sha256Hex(request.body),\n ].join(\"\\n\");\n const scope = `${day}/${request.region}/${request.service}/aws4_request`;\n const stringToSign = [\"AWS4-HMAC-SHA256\", amzDate, scope, await sha256Hex(canonicalRequest)].join(\"\\n\");\n\n let key = await hmac(new TextEncoder().encode(`AWS4${credentials.secretAccessKey}`), day);\n key = await hmac(key, request.region);\n key = await hmac(key, request.service);\n key = await hmac(key, \"aws4_request\");\n const signature = hex(await hmac(key, stringToSign));\n\n headers.authorization = `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;\n return headers;\n}\n\nasync function hmac(key: Uint8Array, data: string): Promise<Uint8Array> {\n const cryptoKey = await crypto.subtle.importKey(\"raw\", key as Uint8Array<ArrayBuffer>, { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n return new Uint8Array(await crypto.subtle.sign(\"HMAC\", cryptoKey, new TextEncoder().encode(data)));\n}\n\n/** RFC 3986 encoding of every byte but the unreserved characters. */\nfunction uriEncode(text: string): string {\n return encodeURIComponent(text).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);\n}\n\nfunction canonicalUri(path: string): string {\n if (!path) return \"/\";\n // URL.pathname is already encoded once; every AWS service but S3 expects each segment encoded again.\n return path.split(\"/\").map(uriEncode).join(\"/\");\n}\n\nfunction canonicalQuery(params: URLSearchParams): string {\n const pairs: [string, string][] = [];\n params.forEach((value, name) => pairs.push([uriEncode(name), uriEncode(value)]));\n pairs.sort(([a, av], [b, bv]) => (a < b ? -1 : a > b ? 1 : av < bv ? -1 : av > bv ? 1 : 0));\n return pairs.map(([n, v]) => `${n}=${v}`).join(\"&\");\n}\n","/**\n * Amazon SES, API v2 SendEmail. API reference: https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html\n * POST https://email.<region>.amazonaws.com/v2/email/outbound-emails, signed\n * with AWS Signature Version 4 (see sigv4.ts), so no AWS SDK is needed.\n */\nimport type { AlertChannel } from \"../types.js\";\nimport { composeEmail, recipients, type EmailOptions } from \"./email.js\";\nimport { post, trimmed } from \"./shared.js\";\nimport { signV4 } from \"./sigv4.js\";\n\nexport interface SesOptions extends EmailOptions {\n /** The SES region, \"us-east-1\" say. The from identity must be verified there. */\n region: string;\n accessKeyId: string;\n secretAccessKey: string;\n /** For temporary credentials, an assumed role say. */\n sessionToken?: string;\n /** A configuration set for event publishing, if you use one. */\n configurationSetName?: string;\n /** The clock used to sign requests, in epoch milliseconds. For tests. */\n now?: () => number;\n}\n\n/** Sends alerts as email through Amazon SES. */\nexport function ses(options: SesOptions): AlertChannel {\n if (!options.region) throw new Error(\"ses() needs a region\");\n if (!/^[a-z0-9-]+$/.test(options.region)) throw new Error(\"ses() needs a region like us-east-1\");\n // A pasted credential often carries a stray space or newline, which would spoil the signature.\n const credentials = { accessKeyId: trimmed(options.accessKeyId), secretAccessKey: trimmed(options.secretAccessKey), sessionToken: trimmed(options.sessionToken) || undefined };\n if (!credentials.accessKeyId || !credentials.secretAccessKey) throw new Error(\"ses() needs an accessKeyId and secretAccessKey\");\n const to = recipients(\"ses\", options);\n const url = `https://email.${options.region}.amazonaws.com/v2/email/outbound-emails`;\n const now = options.now ?? Date.now;\n return {\n name: \"ses\",\n async send(alert) {\n const email = composeEmail(alert, options, to);\n const body = JSON.stringify({\n FromEmailAddress: email.from,\n Destination: { ToAddresses: email.to },\n Content: {\n Simple: {\n Subject: { Data: email.subject, Charset: \"UTF-8\" },\n Body: { Text: { Data: email.text, Charset: \"UTF-8\" }, Html: { Data: email.html, Charset: \"UTF-8\" } },\n },\n },\n ...(options.configurationSetName ? { ConfigurationSetName: options.configurationSetName } : {}),\n EmailTags: [{ Name: \"source\", Value: \"cronwatch\" }],\n });\n const headers = await signV4(\n { method: \"POST\", url, headers: { \"content-type\": \"application/json\" }, body, region: options.region, service: \"ses\", now: now() },\n credentials,\n );\n await post(\"SES\", url, { headers, body }, [credentials.secretAccessKey, credentials.sessionToken]);\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/alerts/shared.ts","../src/alerts/email.ts","../src/alerts/sigv4.ts","../src/alerts/ses.ts"],"names":[],"mappings":"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","file":"ses.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\" || type === \"under_floor\") 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 parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(\"only http and https URLs can be posted to, not this URL\");\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") throw new Error(`only http and https URLs can be posted to, not ${parsed.protocol}`);\n if (parsed.username || parsed.password) throw new Error(\"URLs containing credentials cannot be posted to\");\n return url;\n}\n\n/** Fetch can quote credentials in construction errors. Only safe diagnostics leave this boundary. */\nexport async function fetchPost(url: string, init: { headers: Record<string, string>; body: string }): Promise<Response> {\n const target = postable(url);\n try {\n new Headers(init.headers);\n } catch {\n throw new Error(\"request headers are not valid\");\n }\n try {\n return await fetch(target, { ...init, method: \"POST\", redirect: \"error\", signal: AbortSignal.timeout(TIMEOUT_MS) });\n } catch (error) {\n if ((error as Error)?.name === \"TimeoutError\") throw new DOMException(\"The operation was aborted due to timeout\", \"TimeoutError\");\n throw new Error(`POST ${origin(target)} failed`);\n }\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 fetchPost(url, init);\n if (!response.ok) {\n const text = await errorText(response);\n throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : \"\"}`);\n }\n await drain(response);\n return response;\n}\n\n/** The most of an error body read (64 KiB), far more than any secret plus ERROR_BODY_MAX needs. */\nexport const ERROR_READ_MAX = 64 * 1024;\n\n/**\n * The start of an error answer's body, at most ERROR_READ_MAX bytes read\n * from the stream and the rest let go, so a provider (or something posing\n * as one) cannot make a send hold a body of any size. \"\" when it cannot be\n * read. A character cut at the end decodes as U+FFFD, far past what\n * errorBody keeps.\n */\nexport async function errorText(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n const chunks: Uint8Array[] = [];\n let size = 0;\n try {\n while (size < ERROR_READ_MAX) {\n const { done, value } = await reader.read();\n if (done) break;\n const take = value.subarray(0, ERROR_READ_MAX - size);\n chunks.push(take);\n size += take.byteLength;\n }\n } catch {\n // The status is enough; whatever was read is kept.\n } finally {\n await reader.cancel().catch(() => {});\n }\n const bytes = new Uint8Array(size);\n let at = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, at);\n at += chunk.byteLength;\n }\n return new TextDecoder().decode(bytes);\n}\n\n/**\n * Lets go of a successful answer's body, which nothing reads, so its\n * connection goes back to the pool now rather than when it is collected.\n */\nexport async function drain(response: Response): Promise<void> {\n try {\n await response.body?.cancel();\n } catch {\n // Already read, or the connection is gone: nothing to let go of.\n }\n}\n\n/**\n * The parts of a webhook URL that are its credential, to cut from an error\n * body that echoes the request: its path and query together, then the last\n * segment of its path (Slack's and Discord's token) on its own.\n */\nexport function urlSecrets(url: string): string[] {\n try {\n const parsed = new URL(url);\n const last = parsed.pathname.split(\"/\").pop() ?? \"\";\n return [parsed.pathname + parsed.search, last].filter((part) => part.length > 0 && part !== \"/\");\n } catch {\n return [];\n }\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 * What every email channel sends: one subject, a plain text body, and a small\n * HTML body, so an alert reads the same whichever provider carries it. Not an\n * entry point; resend, postmark, sendgrid, mailgun, and ses each bundle it.\n */\nimport type { Alert } from \"../types.js\";\nimport { cut, plainText } from \"./shared.js\";\n\n/** The options every email channel takes. */\nexport interface EmailOptions {\n /** The sender, \"alerts@example.com\" or \"CronWatch <alerts@example.com>\". The provider must allow it. */\n from: string;\n /** One address or several. */\n to: string | string[];\n /** Put in front of the title in the subject, \"[prod]\" say. */\n subjectPrefix?: string;\n /** Link back to the job in your dashboard: (alert) => `https://app.example.com/cronwatch/jobs/${encodeURIComponent(alert.job)}`. */\n link?: (alert: Alert) => string;\n}\n\nexport interface Email {\n from: string;\n to: string[];\n subject: string;\n text: string;\n html: string;\n}\n\n/** Checks the shared options once, when the channel is made. */\nexport function recipients(name: string, options: EmailOptions): string[] {\n if (!options.from) throw new Error(`${name}() needs a from address`);\n const to = (Array.isArray(options.to) ? options.to : [options.to]).filter((a) => typeof a === \"string\" && a.trim() !== \"\");\n if (to.length === 0) throw new Error(`${name}() needs at least one to address`);\n return to.map((a) => a.trim());\n}\n\nexport function composeEmail(alert: Alert, options: EmailOptions, to: string[]): Email {\n const link = safeLink(options.link?.(alert));\n // One line: a newline in a subject is a header injection or a rejected send.\n const subject = cut(`${options.subjectPrefix ? `${options.subjectPrefix} ` : \"\"}${alert.title}`.replace(/[\\r\\n]+/g, \" \"), 250);\n return { from: options.from, to, subject, text: plainText(alert, link), html: html(alert, link) };\n}\n\n/** Escapes text for HTML content and double quoted attributes. */\nexport function escapeHtml(text: string): string {\n return text.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\").replace(/\"/g, \"&quot;\").replace(/'/g, \"&#39;\");\n}\n\n/** Only http and https links are put in a mail; anything else is dropped. */\nfunction safeLink(link: string | undefined): string | undefined {\n if (!link) return undefined;\n return /^https?:\\/\\//i.test(link) ? link : undefined;\n}\n\nfunction html(alert: Alert, link: string | undefined): string {\n const parts = [\n `<!doctype html>`,\n `<html><body style=\"margin:0;padding:16px;font-family:Georgia,serif;color:#1d1b16;background:#ffffff\">`,\n `<p style=\"margin:0 0 12px;font-size:18px\"><strong>${escapeHtml(alert.title)}</strong></p>`,\n `<pre style=\"margin:0 0 12px;padding:12px;background:#f6f3ec;white-space:pre-wrap;word-break:break-word;font:13px/1.45 Menlo,Consolas,monospace\">${escapeHtml(alert.message)}</pre>`,\n ];\n if (alert.triage) parts.push(`<p style=\"margin:0 0 12px\"><em>Triage:</em> ${escapeHtml(alert.triage)}</p>`);\n if (link) parts.push(`<p style=\"margin:0\"><a href=\"${escapeHtml(link)}\">Open ${escapeHtml(alert.job)}</a></p>`);\n parts.push(`</body></html>`);\n return parts.join(\"\\n\");\n}\n\n/** Splits \"Name <a@b.c>\" into its parts; a bare address has no name. */\nexport function parseAddress(address: string): { email: string; name?: string } {\n const match = /^\\s*(.*?)\\s*<([^<>]+)>\\s*$/.exec(address);\n if (!match) return { email: address.trim() };\n const name = match[1]!.replace(/^\"(.*)\"$/, \"$1\");\n return name ? { email: match[2]!.trim(), name } : { email: match[2]!.trim() };\n}\n","/**\n * AWS Signature Version 4 with Web Crypto, for the SES channel.\n * Spec: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html\n * Checked against the AWS SigV4 test suite (test/sigv4.test.ts).\n */\nimport { hex, sha256Hex } from \"./shared.js\";\n\nexport interface SigV4Credentials {\n accessKeyId: string;\n secretAccessKey: string;\n /** For temporary credentials (STS, an IAM role). Sent and signed as X-Amz-Security-Token. */\n sessionToken?: string;\n}\n\nexport interface SigV4Request {\n method: string;\n url: string;\n /** Headers to send and sign. Host is taken from the URL, and X-Amz-Date from `now`. */\n headers: Record<string, string>;\n body: string;\n region: string;\n service: string;\n /** Epoch milliseconds. */\n now: number;\n}\n\n/**\n * Returns the headers to send: the given ones plus x-amz-date, the session\n * token when there is one, and authorization. Host is signed but not\n * returned, because fetch sets it.\n */\nexport async function signV4(request: SigV4Request, credentials: SigV4Credentials): Promise<Record<string, string>> {\n const url = new URL(request.url);\n const amzDate = new Date(request.now).toISOString().replace(/[-:]/g, \"\").replace(/\\.\\d{3}/, \"\");\n const day = amzDate.slice(0, 8);\n const headers: Record<string, string> = {};\n for (const [name, value] of Object.entries(request.headers)) headers[name.toLowerCase()] = value;\n headers[\"x-amz-date\"] = amzDate;\n if (credentials.sessionToken) headers[\"x-amz-security-token\"] = credentials.sessionToken;\n\n const signed: Record<string, string> = { ...headers, host: url.host };\n const names = Object.keys(signed).sort();\n const canonicalHeaders = names.map((n) => `${n}:${signed[n]!.trim().replace(/\\s+/g, \" \")}\\n`).join(\"\");\n const signedHeaders = names.join(\";\");\n const canonicalRequest = [\n request.method.toUpperCase(),\n canonicalUri(url.pathname),\n canonicalQuery(url.searchParams),\n canonicalHeaders,\n signedHeaders,\n await sha256Hex(request.body),\n ].join(\"\\n\");\n const scope = `${day}/${request.region}/${request.service}/aws4_request`;\n const stringToSign = [\"AWS4-HMAC-SHA256\", amzDate, scope, await sha256Hex(canonicalRequest)].join(\"\\n\");\n\n let key = await hmac(new TextEncoder().encode(`AWS4${credentials.secretAccessKey}`), day);\n key = await hmac(key, request.region);\n key = await hmac(key, request.service);\n key = await hmac(key, \"aws4_request\");\n const signature = hex(await hmac(key, stringToSign));\n\n headers.authorization = `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;\n return headers;\n}\n\nasync function hmac(key: Uint8Array, data: string): Promise<Uint8Array> {\n const cryptoKey = await crypto.subtle.importKey(\"raw\", key as Uint8Array<ArrayBuffer>, { name: \"HMAC\", hash: \"SHA-256\" }, false, [\"sign\"]);\n return new Uint8Array(await crypto.subtle.sign(\"HMAC\", cryptoKey, new TextEncoder().encode(data)));\n}\n\n/** RFC 3986 encoding of every byte but the unreserved characters. */\nfunction uriEncode(text: string): string {\n return encodeURIComponent(text).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);\n}\n\nfunction canonicalUri(path: string): string {\n if (!path) return \"/\";\n // URL.pathname is already encoded once; every AWS service but S3 expects each segment encoded again.\n return path.split(\"/\").map(uriEncode).join(\"/\");\n}\n\nfunction canonicalQuery(params: URLSearchParams): string {\n const pairs: [string, string][] = [];\n params.forEach((value, name) => pairs.push([uriEncode(name), uriEncode(value)]));\n pairs.sort(([a, av], [b, bv]) => (a < b ? -1 : a > b ? 1 : av < bv ? -1 : av > bv ? 1 : 0));\n return pairs.map(([n, v]) => `${n}=${v}`).join(\"&\");\n}\n","/**\n * Amazon SES, API v2 SendEmail. API reference: https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html\n * POST https://email.<region>.amazonaws.com/v2/email/outbound-emails, signed\n * with AWS Signature Version 4 (see sigv4.ts), so no AWS SDK is needed.\n */\nimport type { AlertChannel } from \"../types.js\";\nimport { composeEmail, recipients, type EmailOptions } from \"./email.js\";\nimport { post, trimmed } from \"./shared.js\";\nimport { signV4 } from \"./sigv4.js\";\n\nexport interface SesOptions extends EmailOptions {\n /** The SES region, \"us-east-1\" say. The from identity must be verified there. */\n region: string;\n accessKeyId: string;\n secretAccessKey: string;\n /** For temporary credentials, an assumed role say. */\n sessionToken?: string;\n /** A configuration set for event publishing, if you use one. */\n configurationSetName?: string;\n /** The clock used to sign requests, in epoch milliseconds. For tests. */\n now?: () => number;\n}\n\n/** Sends alerts as email through Amazon SES. */\nexport function ses(options: SesOptions): AlertChannel {\n if (!options.region) throw new Error(\"ses() needs a region\");\n if (!/^[a-z0-9-]+$/.test(options.region)) throw new Error(\"ses() needs a region like us-east-1\");\n // A pasted credential often carries a stray space or newline, which would spoil the signature.\n const credentials = { accessKeyId: trimmed(options.accessKeyId), secretAccessKey: trimmed(options.secretAccessKey), sessionToken: trimmed(options.sessionToken) || undefined };\n if (!credentials.accessKeyId || !credentials.secretAccessKey) throw new Error(\"ses() needs an accessKeyId and secretAccessKey\");\n const to = recipients(\"ses\", options);\n const url = `https://email.${options.region}.amazonaws.com/v2/email/outbound-emails`;\n const now = options.now ?? Date.now;\n return {\n name: \"ses\",\n async send(alert) {\n const email = composeEmail(alert, options, to);\n const body = JSON.stringify({\n FromEmailAddress: email.from,\n Destination: { ToAddresses: email.to },\n Content: {\n Simple: {\n Subject: { Data: email.subject, Charset: \"UTF-8\" },\n Body: { Text: { Data: email.text, Charset: \"UTF-8\" }, Html: { Data: email.html, Charset: \"UTF-8\" } },\n },\n },\n ...(options.configurationSetName ? { ConfigurationSetName: options.configurationSetName } : {}),\n EmailTags: [{ Name: \"source\", Value: \"cronwatch\" }],\n });\n const headers = await signV4(\n { method: \"POST\", url, headers: { \"content-type\": \"application/json\" }, body, region: options.region, service: \"ses\", now: now() },\n credentials,\n );\n await post(\"SES\", url, { headers, body }, [credentials.secretAccessKey, credentials.sessionToken]);\n },\n };\n}\n"]}
package/dist/ses.js CHANGED
@@ -8,18 +8,33 @@ function origin(url) {
8
8
  }
9
9
  }
10
10
  function postable(url) {
11
- let protocol;
11
+ let parsed;
12
12
  try {
13
- protocol = new URL(url).protocol;
13
+ parsed = new URL(url);
14
14
  } catch {
15
15
  throw new Error("only http and https URLs can be posted to, not this URL");
16
16
  }
17
- if (protocol !== "http:" && protocol !== "https:") throw new Error(`only http and https URLs can be posted to, not ${protocol}`);
17
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`only http and https URLs can be posted to, not ${parsed.protocol}`);
18
+ if (parsed.username || parsed.password) throw new Error("URLs containing credentials cannot be posted to");
18
19
  return url;
19
20
  }
21
+ async function fetchPost(url, init) {
22
+ const target = postable(url);
23
+ try {
24
+ new Headers(init.headers);
25
+ } catch {
26
+ throw new Error("request headers are not valid");
27
+ }
28
+ try {
29
+ return await fetch(target, { ...init, method: "POST", redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS) });
30
+ } catch (error) {
31
+ if (error?.name === "TimeoutError") throw new DOMException("The operation was aborted due to timeout", "TimeoutError");
32
+ throw new Error(`POST ${origin(target)} failed`);
33
+ }
34
+ }
20
35
  var ERROR_BODY_MAX = 200;
21
36
  async function post(provider, url, init, secrets = []) {
22
- const response = await fetch(postable(url), { method: "POST", headers: init.headers, body: init.body, redirect: "error", signal: AbortSignal.timeout(TIMEOUT_MS) });
37
+ const response = await fetchPost(url, init);
23
38
  if (!response.ok) {
24
39
  const text = await errorText(response);
25
40
  throw new Error(`${provider} ${origin(url)} answered ${response.status}${text ? `: ${errorBody(text, secrets)}` : ""}`);