@shware/workflow 3.3.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/README.md +230 -40
  2. package/dist/apns.cjs +113 -26
  3. package/dist/apns.cjs.map +1 -1
  4. package/dist/apns.d.cts +52 -6
  5. package/dist/apns.d.cts.map +1 -1
  6. package/dist/apns.d.mts +52 -6
  7. package/dist/apns.d.mts.map +1 -1
  8. package/dist/apns.mjs +111 -27
  9. package/dist/apns.mjs.map +1 -1
  10. package/dist/aws/engine-step.cjs +5 -5
  11. package/dist/aws/engine-step.cjs.map +1 -1
  12. package/dist/aws/engine-step.d.cts +8 -2
  13. package/dist/aws/engine-step.d.cts.map +1 -1
  14. package/dist/aws/engine-step.d.mts +8 -2
  15. package/dist/aws/engine-step.d.mts.map +1 -1
  16. package/dist/aws/engine-step.mjs +5 -5
  17. package/dist/aws/engine-step.mjs.map +1 -1
  18. package/dist/aws/index.cjs +50 -6
  19. package/dist/aws/index.cjs.map +1 -1
  20. package/dist/aws/index.d.cts +4 -3
  21. package/dist/aws/index.d.cts.map +1 -1
  22. package/dist/aws/index.d.mts +4 -3
  23. package/dist/aws/index.d.mts.map +1 -1
  24. package/dist/aws/index.mjs +49 -7
  25. package/dist/aws/index.mjs.map +1 -1
  26. package/dist/aws/instances.cjs +29 -6
  27. package/dist/aws/instances.cjs.map +1 -1
  28. package/dist/aws/instances.d.cts +11 -1
  29. package/dist/aws/instances.d.cts.map +1 -1
  30. package/dist/aws/instances.d.mts +11 -1
  31. package/dist/aws/instances.d.mts.map +1 -1
  32. package/dist/aws/instances.mjs +27 -4
  33. package/dist/aws/instances.mjs.map +1 -1
  34. package/dist/client.cjs +4 -1
  35. package/dist/client.cjs.map +1 -1
  36. package/dist/client.d.cts +2 -2
  37. package/dist/client.d.cts.map +1 -1
  38. package/dist/client.d.mts +2 -2
  39. package/dist/client.d.mts.map +1 -1
  40. package/dist/client.mjs +4 -1
  41. package/dist/client.mjs.map +1 -1
  42. package/dist/cloudflare/bindings.cjs +28 -25
  43. package/dist/cloudflare/bindings.cjs.map +1 -1
  44. package/dist/cloudflare/bindings.d.cts +15 -129
  45. package/dist/cloudflare/bindings.d.cts.map +1 -1
  46. package/dist/cloudflare/bindings.d.mts +15 -129
  47. package/dist/cloudflare/bindings.d.mts.map +1 -1
  48. package/dist/cloudflare/bindings.mjs +28 -23
  49. package/dist/cloudflare/bindings.mjs.map +1 -1
  50. package/dist/cloudflare/d1-env.cjs +6 -6
  51. package/dist/cloudflare/d1-env.cjs.map +1 -1
  52. package/dist/cloudflare/d1-env.d.cts +5 -5
  53. package/dist/cloudflare/d1-env.d.mts +5 -5
  54. package/dist/cloudflare/d1-env.mjs +6 -6
  55. package/dist/cloudflare/d1-env.mjs.map +1 -1
  56. package/dist/cloudflare/index.cjs +29 -18
  57. package/dist/cloudflare/index.d.cts +8 -4
  58. package/dist/cloudflare/index.d.mts +8 -4
  59. package/dist/cloudflare/index.mjs +9 -5
  60. package/dist/cloudflare/runner.cjs +29 -14
  61. package/dist/cloudflare/runner.cjs.map +1 -1
  62. package/dist/cloudflare/runner.d.cts +9 -2
  63. package/dist/cloudflare/runner.d.cts.map +1 -1
  64. package/dist/cloudflare/runner.d.mts +9 -2
  65. package/dist/cloudflare/runner.d.mts.map +1 -1
  66. package/dist/cloudflare/runner.mjs +28 -13
  67. package/dist/cloudflare/runner.mjs.map +1 -1
  68. package/dist/cloudflare/schema.sql +27 -6
  69. package/dist/cloudflare/worker.cjs +19 -9
  70. package/dist/cloudflare/worker.cjs.map +1 -1
  71. package/dist/cloudflare/worker.d.cts +2 -2
  72. package/dist/cloudflare/worker.d.cts.map +1 -1
  73. package/dist/cloudflare/worker.d.mts +2 -2
  74. package/dist/cloudflare/worker.d.mts.map +1 -1
  75. package/dist/cloudflare/worker.mjs +19 -9
  76. package/dist/cloudflare/worker.mjs.map +1 -1
  77. package/dist/config.cjs +21 -5
  78. package/dist/config.cjs.map +1 -1
  79. package/dist/config.mjs +21 -5
  80. package/dist/config.mjs.map +1 -1
  81. package/dist/dsl/action.cjs +3 -6
  82. package/dist/dsl/action.cjs.map +1 -1
  83. package/dist/dsl/action.d.cts +12 -11
  84. package/dist/dsl/action.d.cts.map +1 -1
  85. package/dist/dsl/action.d.mts +12 -11
  86. package/dist/dsl/action.d.mts.map +1 -1
  87. package/dist/dsl/action.mjs +3 -6
  88. package/dist/dsl/action.mjs.map +1 -1
  89. package/dist/dsl/bundle.cjs +35 -31
  90. package/dist/dsl/bundle.cjs.map +1 -1
  91. package/dist/dsl/bundle.d.cts.map +1 -1
  92. package/dist/dsl/bundle.d.mts.map +1 -1
  93. package/dist/dsl/bundle.mjs +35 -31
  94. package/dist/dsl/bundle.mjs.map +1 -1
  95. package/dist/dsl/condition.cjs +13 -1
  96. package/dist/dsl/condition.cjs.map +1 -1
  97. package/dist/dsl/condition.d.cts +9 -1
  98. package/dist/dsl/condition.d.cts.map +1 -1
  99. package/dist/dsl/condition.d.mts +9 -1
  100. package/dist/dsl/condition.d.mts.map +1 -1
  101. package/dist/dsl/condition.mjs +13 -2
  102. package/dist/dsl/condition.mjs.map +1 -1
  103. package/dist/dsl/flow.cjs +33 -11
  104. package/dist/dsl/flow.cjs.map +1 -1
  105. package/dist/dsl/flow.d.cts +9 -1
  106. package/dist/dsl/flow.d.cts.map +1 -1
  107. package/dist/dsl/flow.d.mts +9 -1
  108. package/dist/dsl/flow.d.mts.map +1 -1
  109. package/dist/dsl/flow.mjs +34 -13
  110. package/dist/dsl/flow.mjs.map +1 -1
  111. package/dist/dsl/workflow.cjs +2 -3
  112. package/dist/dsl/workflow.cjs.map +1 -1
  113. package/dist/dsl/workflow.d.cts.map +1 -1
  114. package/dist/dsl/workflow.d.mts.map +1 -1
  115. package/dist/dsl/workflow.mjs +3 -4
  116. package/dist/dsl/workflow.mjs.map +1 -1
  117. package/dist/engine/actions.cjs +1 -8
  118. package/dist/engine/actions.cjs.map +1 -1
  119. package/dist/engine/actions.d.cts +1 -12
  120. package/dist/engine/actions.d.cts.map +1 -1
  121. package/dist/engine/actions.d.mts +1 -12
  122. package/dist/engine/actions.d.mts.map +1 -1
  123. package/dist/engine/actions.mjs +1 -8
  124. package/dist/engine/actions.mjs.map +1 -1
  125. package/dist/engine/condition.cjs +25 -5
  126. package/dist/engine/condition.cjs.map +1 -1
  127. package/dist/engine/condition.d.cts +12 -3
  128. package/dist/engine/condition.d.cts.map +1 -1
  129. package/dist/engine/condition.d.mts +12 -3
  130. package/dist/engine/condition.d.mts.map +1 -1
  131. package/dist/engine/condition.mjs +25 -6
  132. package/dist/engine/condition.mjs.map +1 -1
  133. package/dist/engine/index.cjs +3 -0
  134. package/dist/engine/index.d.cts +5 -5
  135. package/dist/engine/index.d.mts +5 -5
  136. package/dist/engine/index.mjs +4 -4
  137. package/dist/engine/interpreter.cjs +27 -20
  138. package/dist/engine/interpreter.cjs.map +1 -1
  139. package/dist/engine/interpreter.d.cts.map +1 -1
  140. package/dist/engine/interpreter.d.mts.map +1 -1
  141. package/dist/engine/interpreter.mjs +27 -20
  142. package/dist/engine/interpreter.mjs.map +1 -1
  143. package/dist/engine/ports.d.cts +15 -6
  144. package/dist/engine/ports.d.cts.map +1 -1
  145. package/dist/engine/ports.d.mts +15 -6
  146. package/dist/engine/ports.d.mts.map +1 -1
  147. package/dist/engine/retry.cjs +13 -0
  148. package/dist/engine/retry.cjs.map +1 -1
  149. package/dist/engine/retry.d.cts +12 -0
  150. package/dist/engine/retry.d.cts.map +1 -1
  151. package/dist/engine/retry.d.mts +12 -0
  152. package/dist/engine/retry.d.mts.map +1 -1
  153. package/dist/engine/retry.mjs +13 -1
  154. package/dist/engine/retry.mjs.map +1 -1
  155. package/dist/engine/time-scale.cjs +90 -15
  156. package/dist/engine/time-scale.cjs.map +1 -1
  157. package/dist/engine/time-scale.d.cts +9 -6
  158. package/dist/engine/time-scale.d.cts.map +1 -1
  159. package/dist/engine/time-scale.d.mts +9 -6
  160. package/dist/engine/time-scale.d.mts.map +1 -1
  161. package/dist/engine/time-scale.mjs +90 -16
  162. package/dist/engine/time-scale.mjs.map +1 -1
  163. package/dist/fcm.cjs +111 -38
  164. package/dist/fcm.cjs.map +1 -1
  165. package/dist/fcm.d.cts +45 -5
  166. package/dist/fcm.d.cts.map +1 -1
  167. package/dist/fcm.d.mts +45 -5
  168. package/dist/fcm.d.mts.map +1 -1
  169. package/dist/fcm.mjs +110 -39
  170. package/dist/fcm.mjs.map +1 -1
  171. package/dist/hash.cjs +6 -6
  172. package/dist/hash.cjs.map +1 -1
  173. package/dist/hash.mjs +6 -6
  174. package/dist/hash.mjs.map +1 -1
  175. package/dist/host/auth.cjs +49 -0
  176. package/dist/host/auth.cjs.map +1 -0
  177. package/dist/host/auth.d.cts +35 -0
  178. package/dist/host/auth.d.cts.map +1 -0
  179. package/dist/host/auth.d.mts +35 -0
  180. package/dist/host/auth.d.mts.map +1 -0
  181. package/dist/host/auth.mjs +46 -0
  182. package/dist/host/auth.mjs.map +1 -0
  183. package/dist/host/channels.cjs +118 -40
  184. package/dist/host/channels.cjs.map +1 -1
  185. package/dist/host/channels.d.cts +27 -1
  186. package/dist/host/channels.d.cts.map +1 -1
  187. package/dist/host/channels.d.mts +27 -1
  188. package/dist/host/channels.d.mts.map +1 -1
  189. package/dist/host/channels.mjs +121 -44
  190. package/dist/host/channels.mjs.map +1 -1
  191. package/dist/host/execute.cjs +14 -2
  192. package/dist/host/execute.cjs.map +1 -1
  193. package/dist/host/execute.d.cts +4 -4
  194. package/dist/host/execute.d.cts.map +1 -1
  195. package/dist/host/execute.d.mts +4 -4
  196. package/dist/host/execute.d.mts.map +1 -1
  197. package/dist/host/execute.mjs +13 -1
  198. package/dist/host/execute.mjs.map +1 -1
  199. package/dist/host/fresh-read.cjs +30 -0
  200. package/dist/host/fresh-read.cjs.map +1 -0
  201. package/dist/host/fresh-read.d.cts +18 -0
  202. package/dist/host/fresh-read.d.cts.map +1 -0
  203. package/dist/host/fresh-read.d.mts +18 -0
  204. package/dist/host/fresh-read.d.mts.map +1 -0
  205. package/dist/host/fresh-read.mjs +29 -0
  206. package/dist/host/fresh-read.mjs.map +1 -0
  207. package/dist/host/http.cjs +340 -0
  208. package/dist/host/http.cjs.map +1 -0
  209. package/dist/host/http.d.cts +58 -0
  210. package/dist/host/http.d.cts.map +1 -0
  211. package/dist/host/http.d.mts +58 -0
  212. package/dist/host/http.d.mts.map +1 -0
  213. package/dist/host/http.mjs +336 -0
  214. package/dist/host/http.mjs.map +1 -0
  215. package/dist/host/{router.cjs → ingest.cjs} +147 -175
  216. package/dist/host/ingest.cjs.map +1 -0
  217. package/dist/host/{router.d.cts → ingest.d.cts} +48 -30
  218. package/dist/host/ingest.d.cts.map +1 -0
  219. package/dist/host/{router.d.mts → ingest.d.mts} +48 -30
  220. package/dist/host/ingest.d.mts.map +1 -0
  221. package/dist/host/{router.mjs → ingest.mjs} +144 -173
  222. package/dist/host/ingest.mjs.map +1 -0
  223. package/dist/host/journey-host.cjs +66 -93
  224. package/dist/host/journey-host.cjs.map +1 -1
  225. package/dist/host/journey-host.d.cts +31 -26
  226. package/dist/host/journey-host.d.cts.map +1 -1
  227. package/dist/host/journey-host.d.mts +31 -26
  228. package/dist/host/journey-host.d.mts.map +1 -1
  229. package/dist/host/journey-host.mjs +66 -93
  230. package/dist/host/journey-host.mjs.map +1 -1
  231. package/dist/host/platform.cjs +21 -0
  232. package/dist/host/platform.cjs.map +1 -0
  233. package/dist/host/platform.d.cts +126 -0
  234. package/dist/host/platform.d.cts.map +1 -0
  235. package/dist/host/platform.d.mts +126 -0
  236. package/dist/host/platform.d.mts.map +1 -0
  237. package/dist/host/platform.mjs +19 -0
  238. package/dist/host/platform.mjs.map +1 -0
  239. package/dist/host/senders.cjs +18 -9
  240. package/dist/host/senders.cjs.map +1 -1
  241. package/dist/host/senders.d.cts +9 -4
  242. package/dist/host/senders.d.cts.map +1 -1
  243. package/dist/host/senders.d.mts +9 -4
  244. package/dist/host/senders.d.mts.map +1 -1
  245. package/dist/host/senders.mjs +18 -9
  246. package/dist/host/senders.mjs.map +1 -1
  247. package/dist/index.cjs +1 -1
  248. package/dist/index.mjs +1 -1
  249. package/dist/ir.cjs +5 -7
  250. package/dist/ir.cjs.map +1 -1
  251. package/dist/ir.d.cts +3 -12
  252. package/dist/ir.d.cts.map +1 -1
  253. package/dist/ir.d.mts +3 -12
  254. package/dist/ir.d.mts.map +1 -1
  255. package/dist/ir.mjs +5 -7
  256. package/dist/ir.mjs.map +1 -1
  257. package/dist/plan.cjs +27 -33
  258. package/dist/plan.cjs.map +1 -1
  259. package/dist/plan.d.cts +1 -1
  260. package/dist/plan.d.cts.map +1 -1
  261. package/dist/plan.d.mts +1 -1
  262. package/dist/plan.d.mts.map +1 -1
  263. package/dist/plan.mjs +27 -33
  264. package/dist/plan.mjs.map +1 -1
  265. package/dist/reporting/tracking.cjs +14 -4
  266. package/dist/reporting/tracking.cjs.map +1 -1
  267. package/dist/reporting/tracking.d.cts +10 -10
  268. package/dist/reporting/tracking.d.cts.map +1 -1
  269. package/dist/reporting/tracking.d.mts +10 -10
  270. package/dist/reporting/tracking.d.mts.map +1 -1
  271. package/dist/reporting/tracking.mjs +14 -4
  272. package/dist/reporting/tracking.mjs.map +1 -1
  273. package/dist/store/d1.cjs +88 -29
  274. package/dist/store/d1.cjs.map +1 -1
  275. package/dist/store/d1.d.cts +29 -11
  276. package/dist/store/d1.d.cts.map +1 -1
  277. package/dist/store/d1.d.mts +29 -11
  278. package/dist/store/d1.d.mts.map +1 -1
  279. package/dist/store/d1.mjs +88 -29
  280. package/dist/store/d1.mjs.map +1 -1
  281. package/dist/store/erasure.cjs +22 -0
  282. package/dist/store/erasure.cjs.map +1 -0
  283. package/dist/store/erasure.d.cts +18 -0
  284. package/dist/store/erasure.d.cts.map +1 -0
  285. package/dist/store/erasure.d.mts +18 -0
  286. package/dist/store/erasure.d.mts.map +1 -0
  287. package/dist/store/erasure.mjs +21 -0
  288. package/dist/store/erasure.mjs.map +1 -0
  289. package/dist/store/index.cjs +2 -0
  290. package/dist/store/index.cjs.map +1 -1
  291. package/dist/store/index.d.cts +67 -13
  292. package/dist/store/index.d.cts.map +1 -1
  293. package/dist/store/index.d.mts +67 -13
  294. package/dist/store/index.d.mts.map +1 -1
  295. package/dist/store/index.mjs +2 -1
  296. package/dist/store/index.mjs.map +1 -1
  297. package/dist/store/postgres-reporting.cjs +125 -62
  298. package/dist/store/postgres-reporting.cjs.map +1 -1
  299. package/dist/store/postgres-reporting.d.cts +30 -2
  300. package/dist/store/postgres-reporting.d.cts.map +1 -1
  301. package/dist/store/postgres-reporting.d.mts +30 -2
  302. package/dist/store/postgres-reporting.d.mts.map +1 -1
  303. package/dist/store/postgres-reporting.mjs +125 -62
  304. package/dist/store/postgres-reporting.mjs.map +1 -1
  305. package/dist/store/postgres.cjs +179 -60
  306. package/dist/store/postgres.cjs.map +1 -1
  307. package/dist/store/postgres.d.cts +44 -7
  308. package/dist/store/postgres.d.cts.map +1 -1
  309. package/dist/store/postgres.d.mts +44 -7
  310. package/dist/store/postgres.d.mts.map +1 -1
  311. package/dist/store/postgres.mjs +179 -60
  312. package/dist/store/postgres.mjs.map +1 -1
  313. package/dist/store/tables.cjs +7 -5
  314. package/dist/store/tables.cjs.map +1 -1
  315. package/dist/store/tables.d.cts.map +1 -1
  316. package/dist/store/tables.d.mts.map +1 -1
  317. package/dist/store/tables.mjs +7 -5
  318. package/dist/store/tables.mjs.map +1 -1
  319. package/dist/store/user-lock.cjs +43 -0
  320. package/dist/store/user-lock.cjs.map +1 -0
  321. package/dist/store/user-lock.d.cts +32 -0
  322. package/dist/store/user-lock.d.cts.map +1 -0
  323. package/dist/store/user-lock.d.mts +32 -0
  324. package/dist/store/user-lock.d.mts.map +1 -0
  325. package/dist/store/user-lock.mjs +40 -0
  326. package/dist/store/user-lock.mjs.map +1 -0
  327. package/dist/transports/cloudflare-email.cjs +1 -1
  328. package/dist/transports/cloudflare-email.mjs +1 -1
  329. package/dist/transports/ses.cjs +47 -6
  330. package/dist/transports/ses.cjs.map +1 -1
  331. package/dist/transports/ses.d.cts +9 -1
  332. package/dist/transports/ses.d.cts.map +1 -1
  333. package/dist/transports/ses.d.mts +9 -1
  334. package/dist/transports/ses.d.mts.map +1 -1
  335. package/dist/transports/ses.mjs +47 -7
  336. package/dist/transports/ses.mjs.map +1 -1
  337. package/dist/webpush.cjs +133 -47
  338. package/dist/webpush.cjs.map +1 -1
  339. package/dist/webpush.d.cts +49 -8
  340. package/dist/webpush.d.cts.map +1 -1
  341. package/dist/webpush.d.mts +49 -8
  342. package/dist/webpush.d.mts.map +1 -1
  343. package/dist/webpush.mjs +130 -47
  344. package/dist/webpush.mjs.map +1 -1
  345. package/package.json +1 -1
  346. package/src/cloudflare/schema.sql +27 -6
  347. package/dist/host/router.cjs.map +0 -1
  348. package/dist/host/router.d.cts.map +0 -1
  349. package/dist/host/router.d.mts.map +0 -1
  350. package/dist/host/router.mjs.map +0 -1
@@ -1,4 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_engine_retry = require("../engine/retry.cjs");
2
3
  const require_apns = require("../apns.cjs");
3
4
  const require_templates = require("../templates.cjs");
4
5
  const require_config = require("../config.cjs");
@@ -9,7 +10,7 @@ const require_push = require("../push.cjs");
9
10
  const require_react_email = require("../react-email.cjs");
10
11
  const require_webhooks = require("../webhooks.cjs");
11
12
  const require_webpush = require("../webpush.cjs");
12
- const require_host_router = require("./router.cjs");
13
+ const require_host_ingest = require("./ingest.cjs");
13
14
  const require_host_senders = require("./senders.cjs");
14
15
  //#region src/host/channels.ts
15
16
  /**
@@ -30,31 +31,63 @@ function journeyMode(env) {
30
31
  function runtimeFromConfig(config, env) {
31
32
  return require_runtime.resolveRuntime(config?.runtime, journeyMode(env));
32
33
  }
33
- /** A sender that fails every message with the reason the channel cannot deliver. */
34
+ /**
35
+ * A sender that fails every message with the reason the channel cannot deliver — for good: the
36
+ * configuration is the same on every attempt, so the step does not retry into it.
37
+ */
34
38
  function unavailable(reason) {
35
39
  return { async send(message) {
36
- throw new Error(`cannot send '${message.template}' on ${message.channel}: ${reason}`);
40
+ throw new require_engine_retry.PermanentError(`cannot send '${message.template}' on ${message.channel}: ${reason}`);
37
41
  } };
38
42
  }
39
43
  function requireSecret(env, name) {
40
44
  const value = env[name];
41
- if (typeof value !== "string" || value.length === 0) throw new Error(`${name} is not set: the environment must hold it for the configured channel`);
45
+ if (typeof value !== "string" || value.length === 0) throw new require_engine_retry.PermanentError(`${name} is not set: the environment must hold it for the configured channel`);
42
46
  return value;
43
47
  }
48
+ function createProviderCache() {
49
+ const credentials = /* @__PURE__ */ new Map();
50
+ return { get(key, build) {
51
+ if (!credentials.has(key)) credentials.set(key, build());
52
+ return credentials.get(key);
53
+ } };
54
+ }
55
+ /** A number for each fetch implementation, so a credential minted through one is not reused with another. */
56
+ const identities = /* @__PURE__ */ new WeakMap();
57
+ let nextIdentity = 1;
58
+ function identity(value) {
59
+ if (value === void 0) return "global";
60
+ let id = identities.get(value);
61
+ if (id === void 0) {
62
+ id = nextIdentity++;
63
+ identities.set(value, id);
64
+ }
65
+ return String(id);
66
+ }
67
+ /** The first 8 hex of the secret's SHA-256: enough to tell a rotation apart, nothing of the secret. */
68
+ async function secretDigest(secret) {
69
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
70
+ return Array.from(new Uint8Array(digest).slice(0, 4), (b) => b.toString(16).padStart(2, "0")).join("");
71
+ }
44
72
  /**
45
- * A sender built on its first message. A platform's secret is only required
46
- * when something is actually sent there: a journey that only emails must not
47
- * fail because the APNs key is missing from a local `.dev.vars`.
73
+ * A sender built on its first message, then kept for this call. `build` reads the secrets (and
74
+ * so may throw a `PermanentError` naming the missing variable): a platform's secret is only
75
+ * required when something is actually sent there, so a journey that only emails must not fail
76
+ * because the APNs key is missing from a local `.dev.vars`. A build that failed is not kept.
48
77
  */
49
78
  function lazy(build) {
50
79
  let sender;
51
80
  return { async send(message) {
52
- sender ??= build();
53
- return sender.send(message);
81
+ sender ??= build().catch((error) => {
82
+ sender = void 0;
83
+ throw error;
84
+ });
85
+ return (await sender).send(message);
54
86
  } };
55
87
  }
56
88
  function channelsFromConfig(config, env, options = {}) {
57
89
  const mode = journeyMode(env);
90
+ const providers = options.providers ?? createProviderCache();
58
91
  const email = () => {
59
92
  const registry = config.templates?.email;
60
93
  if (registry === void 0) return unavailable("no email templates are registered");
@@ -69,41 +102,85 @@ function channelsFromConfig(config, env, options = {}) {
69
102
  const registry = config.templates?.push;
70
103
  if (registry === void 0) return unavailable("no push templates are registered");
71
104
  const render = require_push.registryPushRenderer(registry);
72
- const onUnregistered = async (message) => {
73
- if (message.device !== void 0 && options.store !== void 0) await require_host_router.removeDevice(env, message.device.token, options.store);
74
- };
105
+ /**
106
+ * A token the provider reports as gone is forgotten (nothing left to deliver to), waking the
107
+ * waits that read devices, as a removal through the API does. Done here, per run, on the
108
+ * settled result, so the credential cached across runs (see `ProviderCache`) never holds a
109
+ * run's store.
110
+ */
111
+ const dropDeadTokens = (provider, deadTokenReasons) => ({ async send(message) {
112
+ const result = await provider.send(message);
113
+ if (result !== void 0 && deadTokenReasons.has(result.reason) && message.device !== void 0 && options.store !== void 0) await require_host_ingest.removeDevice(env, message.device.token, options.store);
114
+ return result;
115
+ } });
75
116
  const platforms = {};
76
117
  const apns = config.apns;
77
- if (apns !== void 0) platforms.ios = lazy(() => new require_apns.ApnsPushSender({
78
- keyId: apns.keyId,
79
- teamId: apns.teamId,
80
- privateKey: require_apns.apnsKeyFromBase64(requireSecret(env, "APNS_PRIVATE_KEY_BASE64")),
81
- topic: apns.topic,
82
- environment: require_config.apnsEnvironment(apns, mode),
83
- render,
84
- onUnregistered,
85
- ...env.APNS_RELAY_ORIGIN === void 0 ? {} : { origin: env.APNS_RELAY_ORIGIN },
86
- ...(options.apnsFetch ?? options.fetch) === void 0 ? {} : { fetch: options.apnsFetch ?? options.fetch }
87
- }));
118
+ if (apns !== void 0) {
119
+ const apnsFetch = options.apnsFetch ?? options.fetch;
120
+ const environment = require_config.apnsEnvironment(apns, mode);
121
+ platforms.ios = dropDeadTokens(lazy(async () => {
122
+ const secret = requireSecret(env, "APNS_PRIVATE_KEY_BASE64");
123
+ const credentials = providers.get(JSON.stringify([
124
+ "apns",
125
+ apns.keyId,
126
+ apns.teamId,
127
+ await secretDigest(secret)
128
+ ]), () => new require_apns.ApnsProviderToken({
129
+ keyId: apns.keyId,
130
+ teamId: apns.teamId,
131
+ privateKey: require_apns.apnsKeyFromBase64(secret)
132
+ }));
133
+ return new require_apns.ApnsPushSender({
134
+ credentials,
135
+ topic: apns.topic,
136
+ environment,
137
+ render,
138
+ ...env.APNS_RELAY_ORIGIN === void 0 ? {} : { origin: env.APNS_RELAY_ORIGIN },
139
+ ...apnsFetch === void 0 ? {} : { fetch: apnsFetch }
140
+ });
141
+ }), require_apns.DEAD_TOKEN_REASONS);
142
+ }
88
143
  const fcm = config.fcm;
89
- if (fcm !== void 0) platforms.android = lazy(() => new require_fcm.FcmPushSender({
90
- serviceAccount: require_fcm.serviceAccountFromBase64(requireSecret(env, "GOOGLE_APPLICATION_CREDENTIALS_BASE64")),
91
- render,
92
- onUnregistered,
93
- ...fcm.androidChannelId === void 0 ? {} : { android: { channel_id: fcm.androidChannelId } },
94
- ...options.fetch === void 0 ? {} : { fetch: options.fetch }
95
- }));
144
+ if (fcm !== void 0) platforms.android = dropDeadTokens(lazy(async () => {
145
+ const secret = requireSecret(env, "GOOGLE_APPLICATION_CREDENTIALS_BASE64");
146
+ const serviceAccount = require_fcm.serviceAccountFromBase64(secret);
147
+ const credentials = providers.get(JSON.stringify([
148
+ "fcm",
149
+ serviceAccount.client_email,
150
+ serviceAccount.project_id,
151
+ identity(options.fetch),
152
+ await secretDigest(secret)
153
+ ]), () => new require_fcm.FcmAccessToken({
154
+ serviceAccount,
155
+ ...options.fetch === void 0 ? {} : { fetch: options.fetch }
156
+ }));
157
+ return new require_fcm.FcmPushSender({
158
+ credentials,
159
+ render,
160
+ ...fcm.androidChannelId === void 0 ? {} : { android: { channel_id: fcm.androidChannelId } },
161
+ ...options.fetch === void 0 ? {} : { fetch: options.fetch }
162
+ });
163
+ }), require_fcm.DEAD_TOKEN_REASONS);
96
164
  const webPush = config.webPush;
97
- if (webPush !== void 0) platforms.web = lazy(() => new require_webpush.WebPushSender({
98
- vapid: {
165
+ if (webPush !== void 0) platforms.web = dropDeadTokens(lazy(async () => {
166
+ const publicKey = requireSecret(env, "VAPID_PUBLIC_KEY");
167
+ const privateKey = requireSecret(env, "VAPID_PRIVATE_KEY");
168
+ const credentials = providers.get(JSON.stringify([
169
+ "vapid",
170
+ webPush.subject,
171
+ publicKey,
172
+ await secretDigest(privateKey)
173
+ ]), () => new require_webpush.VapidSigner({
99
174
  subject: webPush.subject,
100
- publicKey: requireSecret(env, "VAPID_PUBLIC_KEY"),
101
- privateKey: requireSecret(env, "VAPID_PRIVATE_KEY")
102
- },
103
- render,
104
- onUnregistered,
105
- ...options.fetch === void 0 ? {} : { fetch: options.fetch }
106
- }));
175
+ publicKey,
176
+ privateKey
177
+ }));
178
+ return new require_webpush.WebPushSender({
179
+ credentials,
180
+ render,
181
+ ...options.fetch === void 0 ? {} : { fetch: options.fetch }
182
+ });
183
+ }), require_webpush.DEAD_TOKEN_REASONS);
107
184
  if (Object.keys(platforms).length === 0) return unavailable("none of apns, fcm or webPush is configured");
108
185
  return require_host_senders.routeByPlatform(platforms);
109
186
  };
@@ -113,7 +190,7 @@ function channelsFromConfig(config, env, options = {}) {
113
190
  */
114
191
  const webhookUrl = (channel, template) => {
115
192
  const url = template.webhook(env);
116
- if (typeof url !== "string" || url === "") throw new Error(`${channel} template '${template.id}': its webhook URL is not set in the environment`);
193
+ if (typeof url !== "string" || url === "") throw new require_engine_retry.PermanentError(`${channel} template '${template.id}': its webhook URL is not set in the environment`);
117
194
  return url;
118
195
  };
119
196
  const discord = () => {
@@ -155,6 +232,7 @@ function channelsFromConfig(config, env, options = {}) {
155
232
  }
156
233
  //#endregion
157
234
  exports.channelsFromConfig = channelsFromConfig;
235
+ exports.createProviderCache = createProviderCache;
158
236
  exports.journeyMode = journeyMode;
159
237
  exports.runtimeFromConfig = runtimeFromConfig;
160
238
 
@@ -1 +1 @@
1
- {"version":3,"file":"channels.cjs","names":["resolveRuntime","EmailSender","registryRenderer","registryPushRenderer","removeDevice","ApnsPushSender","apnsKeyFromBase64","apnsEnvironment","FcmPushSender","serviceAccountFromBase64","WebPushSender","routeByPlatform","indexTemplates","DiscordWebhookSender","lookupTemplate","renderDiscordContent","SlackWebhookSender","renderSlackContent","routeByChannel"],"sources":["../../src/host/channels.ts"],"sourcesContent":["import { ApnsPushSender, apnsKeyFromBase64 } from '../apns';\nimport type { JourneyEnv } from '../cloudflare/bindings';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport { EmailSender, type EmailTransport } from '../email-sender';\nimport type { MessageSender, OutboundMessage } from '../engine/ports';\nimport { FcmPushSender, serviceAccountFromBase64 } from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n indexTemplates,\n lookupTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport { WebPushSender } from '../webpush';\nimport { removeDevice } from './router';\nimport { routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the host's transport (SES, Cloudflare Email, ...) with each template's addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/** A sender that fails every message with the reason the channel cannot deliver. */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new Error(`cannot send '${message.template}' on ${message.channel}: ${reason}`);\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n throw new Error(`${name} is not set: the environment must hold it for the configured channel`);\n }\n return value;\n}\n\n/**\n * A sender built on its first message. A platform's secret is only required\n * when something is actually sent there: a journey that only emails must not\n * fail because the APNs key is missing from a local `.dev.vars`.\n */\nfunction lazy(build: () => MessageSender): MessageSender {\n let sender: MessageSender | undefined;\n return {\n async send(message) {\n sender ??= build();\n return sender.send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n /** Where finished emails go — the host's choice, independent of where the workflow runs. */\n email?: EmailTransport | undefined;\n fetch?: typeof fetch;\n /** APNs is HTTP/2 only: a Node host passes an HTTP/2 fetch here (its own fetch cannot). */\n apnsFetch?: typeof fetch | undefined;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n const transport = options.email;\n if (transport === undefined) {\n return unavailable(\"no email transport is configured (the host's `email` option)\");\n }\n // Each template names its own sender: there is no global one to require\n return new EmailSender({ transport, render: registryRenderer(registry, config.email) });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n // A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n // waits that read devices, as a removal through the API does\n const onUnregistered = async (message: OutboundMessage) => {\n if (message.device !== undefined && options.store !== undefined) {\n await removeDevice(env, message.device.token, options.store);\n }\n };\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n platforms.ios = lazy(\n () =>\n new ApnsPushSender({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(requireSecret(env, 'APNS_PRIVATE_KEY_BASE64')),\n topic: apns.topic,\n environment: apnsEnvironment(apns, mode),\n render,\n onUnregistered,\n ...(env.APNS_RELAY_ORIGIN === undefined ? {} : { origin: env.APNS_RELAY_ORIGIN }),\n ...((options.apnsFetch ?? options.fetch) === undefined\n ? {}\n : { fetch: options.apnsFetch ?? options.fetch }),\n })\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = lazy(\n () =>\n new FcmPushSender({\n serviceAccount: serviceAccountFromBase64(\n requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64')\n ),\n render,\n onUnregistered,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = lazy(\n () =>\n new WebPushSender({\n vapid: {\n subject: webPush.subject,\n publicKey: requireSecret(env, 'VAPID_PUBLIC_KEY'),\n privateKey: requireSecret(env, 'VAPID_PRIVATE_KEY'),\n },\n render,\n onUnregistered,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (channel: 'discord' | 'slack', template: DiscordTemplate | SlackTemplate) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new Error(\n `${channel} template '${template.id}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n const index = indexTemplates('discord', registry);\n return new DiscordWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'discord', id);\n return {\n url: webhookUrl('discord', template),\n content: renderDiscordContent(template, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n const index = indexTemplates('slack', registry);\n return new SlackWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'slack', id);\n return { url: webhookUrl('slack', template), content: renderSlackContent(template, props) };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"channels.cjs","names":["resolveRuntime","PermanentError","EmailSender","registryRenderer","registryPushRenderer","removeDevice","apnsEnvironment","ApnsProviderToken","apnsKeyFromBase64","ApnsPushSender","APNS_DEAD_TOKEN_REASONS","serviceAccountFromBase64","FcmAccessToken","FcmPushSender","FCM_DEAD_TOKEN_REASONS","VapidSigner","WebPushSender","WEB_PUSH_DEAD_TOKEN_REASONS","routeByPlatform","indexTemplates","DiscordWebhookSender","lookupTemplate","renderDiscordContent","SlackWebhookSender","renderSlackContent","routeByChannel"],"sources":["../../src/host/channels.ts"],"sourcesContent":["import {\n DEAD_TOKEN_REASONS as APNS_DEAD_TOKEN_REASONS,\n ApnsProviderToken,\n ApnsPushSender,\n apnsKeyFromBase64,\n} from '../apns';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport { EmailSender, type EmailTransport } from '../email-sender';\nimport type { MessageSender } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport {\n DEAD_TOKEN_REASONS as FCM_DEAD_TOKEN_REASONS,\n FcmAccessToken,\n FcmPushSender,\n serviceAccountFromBase64,\n} from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n indexTemplates,\n lookupTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport {\n VapidSigner,\n DEAD_TOKEN_REASONS as WEB_PUSH_DEAD_TOKEN_REASONS,\n WebPushSender,\n} from '../webpush';\nimport { removeDevice } from './ingest';\nimport type { JourneyEnv } from './platform';\nimport { routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the host's transport (SES, Cloudflare Email, ...) with each template's addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/**\n * A sender that fails every message with the reason the channel cannot deliver — for good: the\n * configuration is the same on every attempt, so the step does not retry into it.\n */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new PermanentError(\n `cannot send '${message.template}' on ${message.channel}: ${reason}`\n );\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n // Missing on this attempt, missing on the next: the deployment has to change, not the step\n throw new PermanentError(\n `${name} is not set: the environment must hold it for the configured channel`\n );\n }\n return value;\n}\n\n/**\n * The provider credentials — `ApnsProviderToken`, `FcmAccessToken`, `VapidSigner` — for as long\n * as the host lives. `channelsFromConfig` runs once per workflow run (every wake of every\n * instance) and builds the senders anew each time; what must outlive the run is the credential\n * state they hold: the APNs provider JWT must be reused for at least 20 minutes (Apple answers a\n * sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`), the FCM access\n * token is good for an hour, the VAPID key import is wasted work twice.\n *\n * The cache is the host's (`createJourneyHost` makes one and hands it to every run), not the\n * run's `env`: a Lambda invocation builds a fresh env object for each run (its instance port is\n * in it), so a cache keyed on env would miss on every wake. Inside, the key names the provider,\n * the ids that identify the key and a digest of the secret — so a rotated secret or a reloaded\n * host mints a new credential instead of reusing the old, and the secret itself is in no key and\n * no log. Nothing that shapes the sender (topic, environment, relay, registry, store) is in it:\n * the sender is built per call around the shared credential.\n */\nexport interface ProviderCache {\n /** The credential under `key`, built on the first call and returned as is after that. */\n get<T>(key: string, build: () => T): T;\n}\n\nexport function createProviderCache(): ProviderCache {\n const credentials = new Map<string, unknown>();\n return {\n get<T>(key: string, build: () => T): T {\n if (!credentials.has(key)) credentials.set(key, build());\n return credentials.get(key) as T;\n },\n };\n}\n\n/** A number for each fetch implementation, so a credential minted through one is not reused with another. */\nconst identities = new WeakMap<object, number>();\nlet nextIdentity = 1;\nfunction identity(value: object | undefined): string {\n if (value === undefined) return 'global';\n let id = identities.get(value);\n if (id === undefined) {\n id = nextIdentity++;\n identities.set(value, id);\n }\n return String(id);\n}\n\n/** The first 8 hex of the secret's SHA-256: enough to tell a rotation apart, nothing of the secret. */\nasync function secretDigest(secret: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));\n return Array.from(new Uint8Array(digest).slice(0, 4), (b) =>\n b.toString(16).padStart(2, '0')\n ).join('');\n}\n\n/**\n * A sender built on its first message, then kept for this call. `build` reads the secrets (and\n * so may throw a `PermanentError` naming the missing variable): a platform's secret is only\n * required when something is actually sent there, so a journey that only emails must not fail\n * because the APNs key is missing from a local `.dev.vars`. A build that failed is not kept.\n */\nfunction lazy(build: () => Promise<MessageSender>): MessageSender {\n let sender: Promise<MessageSender> | undefined;\n return {\n async send(message) {\n sender ??= build().catch((error: unknown) => {\n sender = undefined;\n throw error;\n });\n return (await sender).send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n /** Where finished emails go — the host's choice, independent of where the workflow runs. */\n email?: EmailTransport | undefined;\n fetch?: typeof fetch;\n /** APNs is HTTP/2 only: a Node host passes an HTTP/2 fetch here (its own fetch cannot). */\n apnsFetch?: typeof fetch | undefined;\n /**\n * Where the provider credentials live across runs (`createProviderCache`): the host's, made\n * once with it. Without one, this call's credentials are its own — nothing is reused.\n */\n providers?: ProviderCache | undefined;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n const providers = options.providers ?? createProviderCache();\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n const transport = options.email;\n if (transport === undefined) {\n return unavailable(\"no email transport is configured (the host's `email` option)\");\n }\n // Each template names its own sender: there is no global one to require\n return new EmailSender({ transport, render: registryRenderer(registry, config.email) });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n /**\n * A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n * waits that read devices, as a removal through the API does. Done here, per run, on the\n * settled result, so the credential cached across runs (see `ProviderCache`) never holds a\n * run's store.\n */\n const dropDeadTokens = (\n provider: MessageSender,\n deadTokenReasons: ReadonlySet<string>\n ): MessageSender => ({\n async send(message) {\n const result = await provider.send(message);\n if (\n result !== undefined &&\n deadTokenReasons.has(result.reason) &&\n message.device !== undefined &&\n options.store !== undefined\n ) {\n await removeDevice(env, message.device.token, options.store);\n }\n return result;\n },\n });\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n const apnsFetch = options.apnsFetch ?? options.fetch;\n const environment = apnsEnvironment(apns, mode);\n platforms.ios = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'APNS_PRIVATE_KEY_BASE64');\n const credentials = providers.get(\n JSON.stringify(['apns', apns.keyId, apns.teamId, await secretDigest(secret)]),\n () =>\n new ApnsProviderToken({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(secret),\n })\n );\n return new ApnsPushSender({\n credentials,\n topic: apns.topic,\n environment,\n render,\n ...(env.APNS_RELAY_ORIGIN === undefined ? {} : { origin: env.APNS_RELAY_ORIGIN }),\n ...(apnsFetch === undefined ? {} : { fetch: apnsFetch }),\n });\n }),\n APNS_DEAD_TOKEN_REASONS\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = dropDeadTokens(\n lazy(async () => {\n const secret = requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64');\n const serviceAccount = serviceAccountFromBase64(secret);\n // The token endpoint is called through the fetch: another fetch is another credential\n const credentials = providers.get(\n JSON.stringify([\n 'fcm',\n serviceAccount.client_email,\n serviceAccount.project_id,\n identity(options.fetch),\n await secretDigest(secret),\n ]),\n () =>\n new FcmAccessToken({\n serviceAccount,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n return new FcmPushSender({\n credentials,\n render,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n FCM_DEAD_TOKEN_REASONS\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = dropDeadTokens(\n lazy(async () => {\n const publicKey = requireSecret(env, 'VAPID_PUBLIC_KEY');\n const privateKey = requireSecret(env, 'VAPID_PRIVATE_KEY');\n const credentials = providers.get(\n JSON.stringify(['vapid', webPush.subject, publicKey, await secretDigest(privateKey)]),\n () => new VapidSigner({ subject: webPush.subject, publicKey, privateKey })\n );\n return new WebPushSender({\n credentials,\n render,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n }),\n WEB_PUSH_DEAD_TOKEN_REASONS\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (channel: 'discord' | 'slack', template: DiscordTemplate | SlackTemplate) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new PermanentError(\n `${channel} template '${template.id}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n const index = indexTemplates('discord', registry);\n return new DiscordWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'discord', id);\n return {\n url: webhookUrl('discord', template),\n content: renderDiscordContent(template, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n const index = indexTemplates('slack', registry);\n return new SlackWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'slack', id);\n return { url: webhookUrl('slack', template), content: renderSlackContent(template, props) };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { MessageSender } from "../engine/ports.cjs";
2
- import { JourneyEnv } from "../cloudflare/bindings.cjs";
2
+ import { JourneyEnv } from "./platform.cjs";
3
3
  import { JourneyStore } from "../store/index.cjs";
4
4
  import { JourneyMode, JourneyRuntimeOptions } from "../runtime.cjs";
5
5
  import { JourneyConfig } from "../config.cjs";
@@ -19,6 +19,27 @@ import { EmailTransport } from "../email-sender.cjs";
19
19
  export declare function journeyMode(env: JourneyEnv): JourneyMode;
20
20
  /** The runtime options for this environment's mode. */
21
21
  export declare function runtimeFromConfig(config: Pick<JourneyConfig, 'runtime'> | undefined, env: JourneyEnv): Required<JourneyRuntimeOptions>;
22
+ /**
23
+ * The provider credentials — `ApnsProviderToken`, `FcmAccessToken`, `VapidSigner` — for as long
24
+ * as the host lives. `channelsFromConfig` runs once per workflow run (every wake of every
25
+ * instance) and builds the senders anew each time; what must outlive the run is the credential
26
+ * state they hold: the APNs provider JWT must be reused for at least 20 minutes (Apple answers a
27
+ * sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`), the FCM access
28
+ * token is good for an hour, the VAPID key import is wasted work twice.
29
+ *
30
+ * The cache is the host's (`createJourneyHost` makes one and hands it to every run), not the
31
+ * run's `env`: a Lambda invocation builds a fresh env object for each run (its instance port is
32
+ * in it), so a cache keyed on env would miss on every wake. Inside, the key names the provider,
33
+ * the ids that identify the key and a digest of the secret — so a rotated secret or a reloaded
34
+ * host mints a new credential instead of reusing the old, and the secret itself is in no key and
35
+ * no log. Nothing that shapes the sender (topic, environment, relay, registry, store) is in it:
36
+ * the sender is built per call around the shared credential.
37
+ */
38
+ export interface ProviderCache {
39
+ /** The credential under `key`, built on the first call and returned as is after that. */
40
+ get<T>(key: string, build: () => T): T;
41
+ }
42
+ export declare function createProviderCache(): ProviderCache;
22
43
  /** The channels slice of the config — what `channelsFromConfig` reads. */
23
44
  export type ChannelsConfig = Pick<JourneyConfig, 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'>;
24
45
  export declare function channelsFromConfig(config: ChannelsConfig, env: JourneyEnv, options?: {
@@ -29,6 +50,11 @@ export declare function channelsFromConfig(config: ChannelsConfig, env: JourneyE
29
50
  fetch?: typeof fetch;
30
51
  /** APNs is HTTP/2 only: a Node host passes an HTTP/2 fetch here (its own fetch cannot). */
31
52
  apnsFetch?: typeof fetch | undefined;
53
+ /**
54
+ * Where the provider credentials live across runs (`createProviderCache`): the host's, made
55
+ * once with it. Without one, this call's credentials are its own — nothing is reused.
56
+ */
57
+ providers?: ProviderCache | undefined;
32
58
  }): MessageSender;
33
59
  //#endregion
34
60
  //# sourceMappingURL=channels.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"channels.d.cts","names":[],"sources":["../../src/host/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;;wBAmCgB,YAAY,KAAK,aAAa;;wBAK9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;YA4CA,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;;EAER,QAAQ;EACR,eAAe;;EAEf,mBAAmB;IAEpB"}
1
+ {"version":3,"file":"channels.d.cts","names":[],"sources":["../../src/host/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;;wBAkDgB,YAAY,KAAK,aAAa;;wBAK9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;;;;;;;;;;;;;;;;iBAoDK;;EAEf,IAAI,GAAG,aAAa,aAAa,IAAI;;wBAGvB,uBAAuB;;YAmD3B,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;;EAER,QAAQ;EACR,eAAe;;EAEf,mBAAmB;;;;;EAKnB,YAAY;IAEb"}
@@ -1,5 +1,5 @@
1
1
  import { MessageSender } from "../engine/ports.mjs";
2
- import { JourneyEnv } from "../cloudflare/bindings.mjs";
2
+ import { JourneyEnv } from "./platform.mjs";
3
3
  import { JourneyStore } from "../store/index.mjs";
4
4
  import { JourneyMode, JourneyRuntimeOptions } from "../runtime.mjs";
5
5
  import { JourneyConfig } from "../config.mjs";
@@ -19,6 +19,27 @@ import { EmailTransport } from "../email-sender.mjs";
19
19
  export declare function journeyMode(env: JourneyEnv): JourneyMode;
20
20
  /** The runtime options for this environment's mode. */
21
21
  export declare function runtimeFromConfig(config: Pick<JourneyConfig, 'runtime'> | undefined, env: JourneyEnv): Required<JourneyRuntimeOptions>;
22
+ /**
23
+ * The provider credentials — `ApnsProviderToken`, `FcmAccessToken`, `VapidSigner` — for as long
24
+ * as the host lives. `channelsFromConfig` runs once per workflow run (every wake of every
25
+ * instance) and builds the senders anew each time; what must outlive the run is the credential
26
+ * state they hold: the APNs provider JWT must be reused for at least 20 minutes (Apple answers a
27
+ * sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`), the FCM access
28
+ * token is good for an hour, the VAPID key import is wasted work twice.
29
+ *
30
+ * The cache is the host's (`createJourneyHost` makes one and hands it to every run), not the
31
+ * run's `env`: a Lambda invocation builds a fresh env object for each run (its instance port is
32
+ * in it), so a cache keyed on env would miss on every wake. Inside, the key names the provider,
33
+ * the ids that identify the key and a digest of the secret — so a rotated secret or a reloaded
34
+ * host mints a new credential instead of reusing the old, and the secret itself is in no key and
35
+ * no log. Nothing that shapes the sender (topic, environment, relay, registry, store) is in it:
36
+ * the sender is built per call around the shared credential.
37
+ */
38
+ export interface ProviderCache {
39
+ /** The credential under `key`, built on the first call and returned as is after that. */
40
+ get<T>(key: string, build: () => T): T;
41
+ }
42
+ export declare function createProviderCache(): ProviderCache;
22
43
  /** The channels slice of the config — what `channelsFromConfig` reads. */
23
44
  export type ChannelsConfig = Pick<JourneyConfig, 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'>;
24
45
  export declare function channelsFromConfig(config: ChannelsConfig, env: JourneyEnv, options?: {
@@ -29,6 +50,11 @@ export declare function channelsFromConfig(config: ChannelsConfig, env: JourneyE
29
50
  fetch?: typeof fetch;
30
51
  /** APNs is HTTP/2 only: a Node host passes an HTTP/2 fetch here (its own fetch cannot). */
31
52
  apnsFetch?: typeof fetch | undefined;
53
+ /**
54
+ * Where the provider credentials live across runs (`createProviderCache`): the host's, made
55
+ * once with it. Without one, this call's credentials are its own — nothing is reused.
56
+ */
57
+ providers?: ProviderCache | undefined;
32
58
  }): MessageSender;
33
59
  //#endregion
34
60
  //# sourceMappingURL=channels.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"channels.d.mts","names":[],"sources":["../../src/host/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;;wBAmCgB,YAAY,KAAK,aAAa;;wBAK9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;YA4CA,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;;EAER,QAAQ;EACR,eAAe;;EAEf,mBAAmB;IAEpB"}
1
+ {"version":3,"file":"channels.d.mts","names":[],"sources":["../../src/host/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;;wBAkDgB,YAAY,KAAK,aAAa;;wBAK9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;;;;;;;;;;;;;;;;iBAoDK;;EAEf,IAAI,GAAG,aAAa,aAAa,IAAI;;wBAGvB,uBAAuB;;YAmD3B,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;;EAER,QAAQ;EACR,eAAe;;EAEf,mBAAmB;;;;;EAKnB,YAAY;IAEb"}
@@ -1,14 +1,15 @@
1
- import { ApnsPushSender, apnsKeyFromBase64 } from "../apns.mjs";
1
+ import { PermanentError } from "../engine/retry.mjs";
2
+ import { ApnsProviderToken, ApnsPushSender, DEAD_TOKEN_REASONS, apnsKeyFromBase64 } from "../apns.mjs";
2
3
  import { indexTemplates, lookupTemplate, renderDiscordContent, renderSlackContent } from "../templates.mjs";
3
4
  import { apnsEnvironment } from "../config.mjs";
4
5
  import { EmailSender } from "../email-sender.mjs";
5
- import { FcmPushSender, serviceAccountFromBase64 } from "../fcm.mjs";
6
+ import { DEAD_TOKEN_REASONS as DEAD_TOKEN_REASONS$1, FcmAccessToken, FcmPushSender, serviceAccountFromBase64 } from "../fcm.mjs";
6
7
  import { resolveRuntime } from "../runtime.mjs";
7
8
  import { registryPushRenderer } from "../push.mjs";
8
9
  import { registryRenderer } from "../react-email.mjs";
9
10
  import { DiscordWebhookSender, SlackWebhookSender } from "../webhooks.mjs";
10
- import { WebPushSender } from "../webpush.mjs";
11
- import { removeDevice } from "./router.mjs";
11
+ import { DEAD_TOKEN_REASONS as DEAD_TOKEN_REASONS$2, VapidSigner, WebPushSender } from "../webpush.mjs";
12
+ import { removeDevice } from "./ingest.mjs";
12
13
  import { routeByChannel, routeByPlatform } from "./senders.mjs";
13
14
  //#region src/host/channels.ts
14
15
  /**
@@ -29,31 +30,63 @@ function journeyMode(env) {
29
30
  function runtimeFromConfig(config, env) {
30
31
  return resolveRuntime(config?.runtime, journeyMode(env));
31
32
  }
32
- /** A sender that fails every message with the reason the channel cannot deliver. */
33
+ /**
34
+ * A sender that fails every message with the reason the channel cannot deliver — for good: the
35
+ * configuration is the same on every attempt, so the step does not retry into it.
36
+ */
33
37
  function unavailable(reason) {
34
38
  return { async send(message) {
35
- throw new Error(`cannot send '${message.template}' on ${message.channel}: ${reason}`);
39
+ throw new PermanentError(`cannot send '${message.template}' on ${message.channel}: ${reason}`);
36
40
  } };
37
41
  }
38
42
  function requireSecret(env, name) {
39
43
  const value = env[name];
40
- if (typeof value !== "string" || value.length === 0) throw new Error(`${name} is not set: the environment must hold it for the configured channel`);
44
+ if (typeof value !== "string" || value.length === 0) throw new PermanentError(`${name} is not set: the environment must hold it for the configured channel`);
41
45
  return value;
42
46
  }
47
+ function createProviderCache() {
48
+ const credentials = /* @__PURE__ */ new Map();
49
+ return { get(key, build) {
50
+ if (!credentials.has(key)) credentials.set(key, build());
51
+ return credentials.get(key);
52
+ } };
53
+ }
54
+ /** A number for each fetch implementation, so a credential minted through one is not reused with another. */
55
+ const identities = /* @__PURE__ */ new WeakMap();
56
+ let nextIdentity = 1;
57
+ function identity(value) {
58
+ if (value === void 0) return "global";
59
+ let id = identities.get(value);
60
+ if (id === void 0) {
61
+ id = nextIdentity++;
62
+ identities.set(value, id);
63
+ }
64
+ return String(id);
65
+ }
66
+ /** The first 8 hex of the secret's SHA-256: enough to tell a rotation apart, nothing of the secret. */
67
+ async function secretDigest(secret) {
68
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
69
+ return Array.from(new Uint8Array(digest).slice(0, 4), (b) => b.toString(16).padStart(2, "0")).join("");
70
+ }
43
71
  /**
44
- * A sender built on its first message. A platform's secret is only required
45
- * when something is actually sent there: a journey that only emails must not
46
- * fail because the APNs key is missing from a local `.dev.vars`.
72
+ * A sender built on its first message, then kept for this call. `build` reads the secrets (and
73
+ * so may throw a `PermanentError` naming the missing variable): a platform's secret is only
74
+ * required when something is actually sent there, so a journey that only emails must not fail
75
+ * because the APNs key is missing from a local `.dev.vars`. A build that failed is not kept.
47
76
  */
48
77
  function lazy(build) {
49
78
  let sender;
50
79
  return { async send(message) {
51
- sender ??= build();
52
- return sender.send(message);
80
+ sender ??= build().catch((error) => {
81
+ sender = void 0;
82
+ throw error;
83
+ });
84
+ return (await sender).send(message);
53
85
  } };
54
86
  }
55
87
  function channelsFromConfig(config, env, options = {}) {
56
88
  const mode = journeyMode(env);
89
+ const providers = options.providers ?? createProviderCache();
57
90
  const email = () => {
58
91
  const registry = config.templates?.email;
59
92
  if (registry === void 0) return unavailable("no email templates are registered");
@@ -68,41 +101,85 @@ function channelsFromConfig(config, env, options = {}) {
68
101
  const registry = config.templates?.push;
69
102
  if (registry === void 0) return unavailable("no push templates are registered");
70
103
  const render = registryPushRenderer(registry);
71
- const onUnregistered = async (message) => {
72
- if (message.device !== void 0 && options.store !== void 0) await removeDevice(env, message.device.token, options.store);
73
- };
104
+ /**
105
+ * A token the provider reports as gone is forgotten (nothing left to deliver to), waking the
106
+ * waits that read devices, as a removal through the API does. Done here, per run, on the
107
+ * settled result, so the credential cached across runs (see `ProviderCache`) never holds a
108
+ * run's store.
109
+ */
110
+ const dropDeadTokens = (provider, deadTokenReasons) => ({ async send(message) {
111
+ const result = await provider.send(message);
112
+ if (result !== void 0 && deadTokenReasons.has(result.reason) && message.device !== void 0 && options.store !== void 0) await removeDevice(env, message.device.token, options.store);
113
+ return result;
114
+ } });
74
115
  const platforms = {};
75
116
  const apns = config.apns;
76
- if (apns !== void 0) platforms.ios = lazy(() => new ApnsPushSender({
77
- keyId: apns.keyId,
78
- teamId: apns.teamId,
79
- privateKey: apnsKeyFromBase64(requireSecret(env, "APNS_PRIVATE_KEY_BASE64")),
80
- topic: apns.topic,
81
- environment: apnsEnvironment(apns, mode),
82
- render,
83
- onUnregistered,
84
- ...env.APNS_RELAY_ORIGIN === void 0 ? {} : { origin: env.APNS_RELAY_ORIGIN },
85
- ...(options.apnsFetch ?? options.fetch) === void 0 ? {} : { fetch: options.apnsFetch ?? options.fetch }
86
- }));
117
+ if (apns !== void 0) {
118
+ const apnsFetch = options.apnsFetch ?? options.fetch;
119
+ const environment = apnsEnvironment(apns, mode);
120
+ platforms.ios = dropDeadTokens(lazy(async () => {
121
+ const secret = requireSecret(env, "APNS_PRIVATE_KEY_BASE64");
122
+ const credentials = providers.get(JSON.stringify([
123
+ "apns",
124
+ apns.keyId,
125
+ apns.teamId,
126
+ await secretDigest(secret)
127
+ ]), () => new ApnsProviderToken({
128
+ keyId: apns.keyId,
129
+ teamId: apns.teamId,
130
+ privateKey: apnsKeyFromBase64(secret)
131
+ }));
132
+ return new ApnsPushSender({
133
+ credentials,
134
+ topic: apns.topic,
135
+ environment,
136
+ render,
137
+ ...env.APNS_RELAY_ORIGIN === void 0 ? {} : { origin: env.APNS_RELAY_ORIGIN },
138
+ ...apnsFetch === void 0 ? {} : { fetch: apnsFetch }
139
+ });
140
+ }), DEAD_TOKEN_REASONS);
141
+ }
87
142
  const fcm = config.fcm;
88
- if (fcm !== void 0) platforms.android = lazy(() => new FcmPushSender({
89
- serviceAccount: serviceAccountFromBase64(requireSecret(env, "GOOGLE_APPLICATION_CREDENTIALS_BASE64")),
90
- render,
91
- onUnregistered,
92
- ...fcm.androidChannelId === void 0 ? {} : { android: { channel_id: fcm.androidChannelId } },
93
- ...options.fetch === void 0 ? {} : { fetch: options.fetch }
94
- }));
143
+ if (fcm !== void 0) platforms.android = dropDeadTokens(lazy(async () => {
144
+ const secret = requireSecret(env, "GOOGLE_APPLICATION_CREDENTIALS_BASE64");
145
+ const serviceAccount = serviceAccountFromBase64(secret);
146
+ const credentials = providers.get(JSON.stringify([
147
+ "fcm",
148
+ serviceAccount.client_email,
149
+ serviceAccount.project_id,
150
+ identity(options.fetch),
151
+ await secretDigest(secret)
152
+ ]), () => new FcmAccessToken({
153
+ serviceAccount,
154
+ ...options.fetch === void 0 ? {} : { fetch: options.fetch }
155
+ }));
156
+ return new FcmPushSender({
157
+ credentials,
158
+ render,
159
+ ...fcm.androidChannelId === void 0 ? {} : { android: { channel_id: fcm.androidChannelId } },
160
+ ...options.fetch === void 0 ? {} : { fetch: options.fetch }
161
+ });
162
+ }), DEAD_TOKEN_REASONS$1);
95
163
  const webPush = config.webPush;
96
- if (webPush !== void 0) platforms.web = lazy(() => new WebPushSender({
97
- vapid: {
164
+ if (webPush !== void 0) platforms.web = dropDeadTokens(lazy(async () => {
165
+ const publicKey = requireSecret(env, "VAPID_PUBLIC_KEY");
166
+ const privateKey = requireSecret(env, "VAPID_PRIVATE_KEY");
167
+ const credentials = providers.get(JSON.stringify([
168
+ "vapid",
169
+ webPush.subject,
170
+ publicKey,
171
+ await secretDigest(privateKey)
172
+ ]), () => new VapidSigner({
98
173
  subject: webPush.subject,
99
- publicKey: requireSecret(env, "VAPID_PUBLIC_KEY"),
100
- privateKey: requireSecret(env, "VAPID_PRIVATE_KEY")
101
- },
102
- render,
103
- onUnregistered,
104
- ...options.fetch === void 0 ? {} : { fetch: options.fetch }
105
- }));
174
+ publicKey,
175
+ privateKey
176
+ }));
177
+ return new WebPushSender({
178
+ credentials,
179
+ render,
180
+ ...options.fetch === void 0 ? {} : { fetch: options.fetch }
181
+ });
182
+ }), DEAD_TOKEN_REASONS$2);
106
183
  if (Object.keys(platforms).length === 0) return unavailable("none of apns, fcm or webPush is configured");
107
184
  return routeByPlatform(platforms);
108
185
  };
@@ -112,7 +189,7 @@ function channelsFromConfig(config, env, options = {}) {
112
189
  */
113
190
  const webhookUrl = (channel, template) => {
114
191
  const url = template.webhook(env);
115
- if (typeof url !== "string" || url === "") throw new Error(`${channel} template '${template.id}': its webhook URL is not set in the environment`);
192
+ if (typeof url !== "string" || url === "") throw new PermanentError(`${channel} template '${template.id}': its webhook URL is not set in the environment`);
116
193
  return url;
117
194
  };
118
195
  const discord = () => {
@@ -153,6 +230,6 @@ function channelsFromConfig(config, env, options = {}) {
153
230
  });
154
231
  }
155
232
  //#endregion
156
- export { channelsFromConfig, journeyMode, runtimeFromConfig };
233
+ export { channelsFromConfig, createProviderCache, journeyMode, runtimeFromConfig };
157
234
 
158
235
  //# sourceMappingURL=channels.mjs.map