@shware/workflow 3.3.0 → 4.0.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 +91 -29
  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 +47 -6
  19. package/dist/aws/index.cjs.map +1 -1
  20. package/dist/aws/index.d.cts +2 -2
  21. package/dist/aws/index.d.cts.map +1 -1
  22. package/dist/aws/index.d.mts +2 -2
  23. package/dist/aws/index.d.mts.map +1 -1
  24. package/dist/aws/index.mjs +47 -6
  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 +28 -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 +21 -0
  178. package/dist/host/auth.d.cts.map +1 -0
  179. package/dist/host/auth.d.mts +21 -0
  180. package/dist/host/auth.d.mts.map +1 -0
  181. package/dist/host/auth.mjs +47 -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 +321 -0
  208. package/dist/host/http.cjs.map +1 -0
  209. package/dist/host/http.d.cts +54 -0
  210. package/dist/host/http.d.cts.map +1 -0
  211. package/dist/host/http.d.mts +54 -0
  212. package/dist/host/http.d.mts.map +1 -0
  213. package/dist/host/http.mjs +317 -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 +65 -93
  224. package/dist/host/journey-host.cjs.map +1 -1
  225. package/dist/host/journey-host.d.cts +19 -26
  226. package/dist/host/journey-host.d.cts.map +1 -1
  227. package/dist/host/journey-host.d.mts +19 -26
  228. package/dist/host/journey-host.d.mts.map +1 -1
  229. package/dist/host/journey-host.mjs +65 -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
package/dist/fcm.cjs CHANGED
@@ -1,16 +1,90 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_engine_retry = require("./engine/retry.cjs");
2
3
  const require_jwt = require("./jwt.cjs");
3
4
  //#region src/fcm.ts
4
- /** Errors FCM reports for a token that will never deliver again — not worth a retry. */
5
- const DEAD_TOKEN_CODES = /* @__PURE__ */ new Set([
6
- "UNREGISTERED",
7
- "INVALID_ARGUMENT",
8
- "SENDER_ID_MISMATCH"
5
+ /**
6
+ * How an FCM v1 answer is classified, after the API's error codes
7
+ * (https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode):
8
+ *
9
+ * - dead token: 404 `UNREGISTERED` (the app was uninstalled, or the token expired) — also a 404
10
+ * whose only code is the gRPC status `NOT_FOUND`. The copy settles undelivered and the host
11
+ * forgets the token.
12
+ * - undelivered, device kept: 400 `INVALID_ARGUMENT`. FCM gives it for a malformed token but
13
+ * just as well for a payload problem (a message too big, a bad data key, a bad TTL), which
14
+ * would hit every device of the template at once; the copy settles with the reason, so the
15
+ * report shows it, and nothing is deleted on a guess.
16
+ * - configuration: 403 `SENDER_ID_MISMATCH` (the token belongs to another Firebase project) and
17
+ * 401 `THIRD_PARTY_AUTH_ERROR` (the APNs or web push credentials in the Firebase project):
18
+ * `PermanentError`, devices kept.
19
+ * - transient: 401 `UNAUTHENTICATED` (the cached access token is dropped first), 429
20
+ * `QUOTA_EXCEEDED`, 503 `UNAVAILABLE`, 500 `INTERNAL` and any other 429 / 5xx.
21
+ * - everything else (another 4xx) is this request itself: a `PermanentError`.
22
+ */
23
+ const DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set(["UNREGISTERED", "NOT_FOUND"]);
24
+ const CONFIGURATION_CODES = /* @__PURE__ */ new Set(["SENDER_ID_MISMATCH", "THIRD_PARTY_AUTH_ERROR"]);
25
+ const TRANSIENT_CODES = /* @__PURE__ */ new Set([
26
+ "QUOTA_EXCEEDED",
27
+ "UNAVAILABLE",
28
+ "INTERNAL"
9
29
  ]);
10
30
  const SCOPE = "https://www.googleapis.com/auth/firebase.messaging";
31
+ /**
32
+ * The OAuth2 access token for one service account, exchanged once and reused until a minute
33
+ * before it expires. Concurrent callers without a usable token share the one exchange in flight,
34
+ * and an exchange that failed is not kept, so the next call tries again. One instance serves every
35
+ * sender built on the account — a host keeps it across runs, and builds the senders per run.
36
+ */
37
+ var FcmAccessToken = class {
38
+ options;
39
+ /** The Firebase project the account belongs to — the `messages:send` URL names it. */
40
+ projectId;
41
+ minted;
42
+ minting;
43
+ constructor(options) {
44
+ this.options = options;
45
+ this.projectId = options.serviceAccount.project_id;
46
+ }
47
+ /** The current token, exchanged now if there is none or it is about to expire. */
48
+ async token(now = Date.now()) {
49
+ if (this.minted !== void 0 && this.minted.expiresAt - 6e4 > now) return this.minted.value;
50
+ this.minting ??= this.exchange(now).then((minted) => {
51
+ this.minted = minted;
52
+ return minted.value;
53
+ }).finally(() => {
54
+ this.minting = void 0;
55
+ });
56
+ return this.minting;
57
+ }
58
+ /** Forget the token: FCM refused it (401), so the next call exchanges a new one. */
59
+ invalidate() {
60
+ this.minted = void 0;
61
+ }
62
+ async exchange(now) {
63
+ const { serviceAccount } = this.options;
64
+ const tokenUri = serviceAccount.token_uri ?? "https://oauth2.googleapis.com/token";
65
+ const assertion = await signJwt(serviceAccount, tokenUri, SCOPE, now);
66
+ const response = await (this.options.fetch ?? fetch)(tokenUri, {
67
+ method: "POST",
68
+ headers: { "content-type": "application/x-www-form-urlencoded" },
69
+ body: new URLSearchParams({
70
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
71
+ assertion
72
+ })
73
+ });
74
+ if (!response.ok) {
75
+ const detail = `${response.status} ${await response.text()}`;
76
+ if (require_engine_retry.isRetryableStatus(response.status)) throw new Error(`FCM token exchange failed: ${detail}`);
77
+ throw new require_engine_retry.PermanentError(`FCM token exchange refused for '${serviceAccount.client_email}': ${detail}`);
78
+ }
79
+ const json = await response.json();
80
+ return {
81
+ value: json.access_token,
82
+ expiresAt: now + json.expires_in * 1e3
83
+ };
84
+ }
85
+ };
11
86
  var FcmPushSender = class {
12
87
  options;
13
- token;
14
88
  constructor(options) {
15
89
  this.options = options;
16
90
  }
@@ -18,11 +92,11 @@ var FcmPushSender = class {
18
92
  if (message.channel !== "push") throw new Error(`FcmPushSender: unsupported channel '${message.channel}'`);
19
93
  if (message.recipient === void 0) throw new Error(`FcmPushSender: no push token for user '${message.userId}'`);
20
94
  const { title, body, image, url } = await this.options.render(message.template, message.props);
21
- const { serviceAccount, android, apns } = this.options;
22
- const response = await (this.options.fetch ?? fetch)(`https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`, {
95
+ const { credentials, android, apns } = this.options;
96
+ const response = await (this.options.fetch ?? fetch)(`https://fcm.googleapis.com/v1/projects/${credentials.projectId}/messages:send`, {
23
97
  method: "POST",
24
98
  headers: {
25
- authorization: `Bearer ${await this.accessToken()}`,
99
+ authorization: `Bearer ${await credentials.token()}`,
26
100
  "content-type": "application/json"
27
101
  },
28
102
  body: JSON.stringify({ message: {
@@ -47,37 +121,29 @@ var FcmPushSender = class {
47
121
  });
48
122
  if (response.ok) return;
49
123
  const text = await response.text();
50
- if (DEAD_TOKEN_CODES.has(fcmErrorCode(text))) {
51
- console.warn(`[fcm] token gone for user ${message.userId}: ${fcmErrorCode(text)}`);
52
- await this.options.onUnregistered?.(message);
124
+ const { status } = response;
125
+ const code = fcmErrorCode(text);
126
+ if (code === "UNREGISTERED" || status === 404 && DEAD_TOKEN_REASONS.has(code)) {
127
+ console.warn(`[fcm] token gone for user ${message.userId}: ${code}`);
53
128
  return {
54
129
  delivered: false,
55
- reason: fcmErrorCode(text)
130
+ reason: code
56
131
  };
57
132
  }
58
- throw new Error(`FCM send failed: ${response.status} ${text}`);
59
- }
60
- async accessToken() {
61
- const now = Date.now();
62
- if (this.token !== void 0 && this.token.expiresAt - 6e4 > now) return this.token.value;
63
- const { serviceAccount } = this.options;
64
- const tokenUri = serviceAccount.token_uri ?? "https://oauth2.googleapis.com/token";
65
- const assertion = await signJwt(serviceAccount, tokenUri, SCOPE, now);
66
- const response = await (this.options.fetch ?? fetch)(tokenUri, {
67
- method: "POST",
68
- headers: { "content-type": "application/x-www-form-urlencoded" },
69
- body: new URLSearchParams({
70
- grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
71
- assertion
72
- })
73
- });
74
- if (!response.ok) throw new Error(`FCM token exchange failed: ${response.status} ${await response.text()}`);
75
- const json = await response.json();
76
- this.token = {
77
- value: json.access_token,
78
- expiresAt: now + json.expires_in * 1e3
79
- };
80
- return json.access_token;
133
+ if (code === "INVALID_ARGUMENT") {
134
+ console.warn(`[fcm] refused for user ${message.userId}: ${status} ${text}`);
135
+ return {
136
+ delivered: false,
137
+ reason: code
138
+ };
139
+ }
140
+ if (CONFIGURATION_CODES.has(code)) throw new require_engine_retry.PermanentError(`FCM refused the request for project '${credentials.projectId}': ${status} ${code}`);
141
+ if (status === 401 || code === "UNAUTHENTICATED") {
142
+ credentials.invalidate();
143
+ throw new Error(`FCM send failed: ${status} ${code || text} (access token renewed for the retry)`);
144
+ }
145
+ if (TRANSIENT_CODES.has(code) || require_engine_retry.isRetryableStatus(status)) throw new Error(`FCM send failed: ${status} ${code || text}`);
146
+ throw new require_engine_retry.PermanentError(`FCM refused the request: ${status} ${code || text}`);
81
147
  }
82
148
  };
83
149
  /** FCM v1 error bodies carry the code at `error.status` and, for messaging, in `error.details[].errorCode`. */
@@ -112,11 +178,18 @@ async function signJwt(account, audience, scope, nowMs = Date.now()) {
112
178
  }
113
179
  /** Parse the service-account JSON as it is usually shipped: base64 of the file, in one secret. */
114
180
  function serviceAccountFromBase64(value) {
115
- const json = JSON.parse(atob(value));
116
- if (!json.project_id || !json.client_email || !json.private_key) throw new Error("service account JSON is missing project_id, client_email or private_key");
181
+ let json;
182
+ try {
183
+ json = JSON.parse(atob(value));
184
+ } catch (error) {
185
+ throw new require_engine_retry.PermanentError("service account is not base64-encoded JSON", { cause: error });
186
+ }
187
+ if (!json.project_id || !json.client_email || !json.private_key) throw new require_engine_retry.PermanentError("service account JSON is missing project_id, client_email or private_key");
117
188
  return json;
118
189
  }
119
190
  //#endregion
191
+ exports.DEAD_TOKEN_REASONS = DEAD_TOKEN_REASONS;
192
+ exports.FcmAccessToken = FcmAccessToken;
120
193
  exports.FcmPushSender = FcmPushSender;
121
194
  exports.fcmErrorCode = fcmErrorCode;
122
195
  exports.serviceAccountFromBase64 = serviceAccountFromBase64;
package/dist/fcm.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"fcm.cjs","names":["base64url","pemToDer"],"sources":["../src/fcm.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { base64url, pemToDer } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/fcm` — push delivery through Firebase Cloud Messaging's\n * HTTP v1 API, from a service account. No dependencies: the OAuth2 access\n * token is minted by signing a JWT with WebCrypto (Workers, Node 20+, Deno,\n * Bun), so this runs wherever the engine runs.\n *\n * The recipient is an FCM registration token the app registered for the user\n * (`registerDevice`); the engine sends one message per device.\n */\n\n/** The fields of a Google service-account JSON this sender needs. */\nexport interface FcmServiceAccount {\n project_id: string;\n client_email: string;\n /** PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8). */\n private_key: string;\n token_uri?: string;\n}\n\nexport interface FcmPushOptions {\n serviceAccount: FcmServiceAccount;\n render: PushRenderer;\n /** Android-specific delivery options, e.g. `{ channel_id: 'default' }` (FCM v1 `android.notification`). */\n android?: Record<string, unknown>;\n /** APNs-specific payload options merged into `aps`, e.g. `{ sound: 'default' }`. */\n apns?: Record<string, unknown>;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n /** Called for a token FCM reports as gone (`UNREGISTERED`); `channelsFromConfig` removes the device. */\n onUnregistered?: (message: OutboundMessage) => Promise<void> | void;\n}\n\n/** Errors FCM reports for a token that will never deliver again — not worth a retry. */\nconst DEAD_TOKEN_CODES = new Set(['UNREGISTERED', 'INVALID_ARGUMENT', 'SENDER_ID_MISMATCH']);\n\nconst SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';\n\nexport class FcmPushSender implements MessageSender {\n private token: { value: string; expiresAt: number } | undefined;\n\n constructor(private readonly options: FcmPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`FcmPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`FcmPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n const { serviceAccount, android, apns } = this.options;\n const doFetch = this.options.fetch ?? fetch;\n\n const response = await doFetch(\n `https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${await this.accessToken()}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n message: {\n token: message.recipient,\n notification: { title, body, ...(image === undefined ? {} : { image }) },\n // The template key travels as data so the app can route a tap; the\n // idempotency key lets the app de-duplicate a redelivered push.\n data: {\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n },\n ...(android === undefined ? {} : { android: { notification: android } }),\n apns: { payload: { aps: { sound: 'default', ...apns } } },\n },\n }),\n }\n );\n if (response.ok) return;\n\n const text = await response.text();\n if (DEAD_TOKEN_CODES.has(fcmErrorCode(text))) {\n // A dead token is a fact about the recipient, not a transient failure:\n // report it and move on, so the step does not retry into the same wall.\n console.warn(`[fcm] token gone for user ${message.userId}: ${fcmErrorCode(text)}`);\n await this.options.onUnregistered?.(message);\n return { delivered: false, reason: fcmErrorCode(text) };\n }\n throw new Error(`FCM send failed: ${response.status} ${text}`);\n }\n\n private async accessToken(): Promise<string> {\n const now = Date.now();\n if (this.token !== undefined && this.token.expiresAt - 60_000 > now) return this.token.value;\n\n const { serviceAccount } = this.options;\n const tokenUri = serviceAccount.token_uri ?? 'https://oauth2.googleapis.com/token';\n const assertion = await signJwt(serviceAccount, tokenUri, SCOPE, now);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(tokenUri, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion,\n }),\n });\n if (!response.ok) {\n throw new Error(`FCM token exchange failed: ${response.status} ${await response.text()}`);\n }\n const json = (await response.json()) as { access_token: string; expires_in: number };\n this.token = { value: json.access_token, expiresAt: now + json.expires_in * 1000 };\n return json.access_token;\n }\n}\n\n/** FCM v1 error bodies carry the code at `error.status` and, for messaging, in `error.details[].errorCode`. */\nexport function fcmErrorCode(body: string): string {\n try {\n const parsed = JSON.parse(body) as {\n error?: { status?: string; details?: { errorCode?: string }[] };\n };\n const detail = parsed.error?.details?.find((d) => d.errorCode !== undefined)?.errorCode;\n return detail ?? parsed.error?.status ?? '';\n } catch {\n return '';\n }\n}\n\n/** RS256-sign the OAuth2 JWT assertion for a service account. */\nexport async function signJwt(\n account: FcmServiceAccount,\n audience: string,\n scope: string,\n nowMs = Date.now()\n): Promise<string> {\n const iat = Math.floor(nowMs / 1000);\n const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));\n const claims = base64url(\n JSON.stringify({ iss: account.client_email, scope, aud: audience, iat, exp: iat + 3600 })\n );\n const key = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(account.private_key),\n { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },\n false,\n ['sign']\n );\n const signature = await crypto.subtle.sign(\n 'RSASSA-PKCS1-v1_5',\n key,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** Parse the service-account JSON as it is usually shipped: base64 of the file, in one secret. */\nexport function serviceAccountFromBase64(value: string): FcmServiceAccount {\n const json = JSON.parse(atob(value)) as Partial<FcmServiceAccount>;\n if (!json.project_id || !json.client_email || !json.private_key) {\n throw new Error('service account JSON is missing project_id, client_email or private_key');\n }\n return json as FcmServiceAccount;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"fcm.cjs","names":["isRetryableStatus","PermanentError","base64url","pemToDer"],"sources":["../src/fcm.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { PermanentError, isRetryableStatus } from './engine/retry';\nimport { base64url, pemToDer } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/fcm` — push delivery through Firebase Cloud Messaging's\n * HTTP v1 API, from a service account. No dependencies: the OAuth2 access\n * token is minted by signing a JWT with WebCrypto (Workers, Node 20+, Deno,\n * Bun), so this runs wherever the engine runs.\n *\n * The recipient is an FCM registration token the app registered for the user\n * (`registerDevice`); the engine sends one message per device.\n */\n\n/** The fields of a Google service-account JSON this sender needs. */\nexport interface FcmServiceAccount {\n project_id: string;\n client_email: string;\n /** PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8). */\n private_key: string;\n token_uri?: string;\n}\n\nexport interface FcmAccessTokenOptions {\n serviceAccount: FcmServiceAccount;\n /** Fetch implementation for the token exchange (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport interface FcmPushOptions {\n /** The access token the requests carry — shared by every sender on the same service account. */\n credentials: FcmAccessToken;\n render: PushRenderer;\n /** Android-specific delivery options, e.g. `{ channel_id: 'default' }` (FCM v1 `android.notification`). */\n android?: Record<string, unknown>;\n /** APNs-specific payload options merged into `aps`, e.g. `{ sound: 'default' }`. */\n apns?: Record<string, unknown>;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\n/**\n * How an FCM v1 answer is classified, after the API's error codes\n * (https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode):\n *\n * - dead token: 404 `UNREGISTERED` (the app was uninstalled, or the token expired) — also a 404\n * whose only code is the gRPC status `NOT_FOUND`. The copy settles undelivered and the host\n * forgets the token.\n * - undelivered, device kept: 400 `INVALID_ARGUMENT`. FCM gives it for a malformed token but\n * just as well for a payload problem (a message too big, a bad data key, a bad TTL), which\n * would hit every device of the template at once; the copy settles with the reason, so the\n * report shows it, and nothing is deleted on a guess.\n * - configuration: 403 `SENDER_ID_MISMATCH` (the token belongs to another Firebase project) and\n * 401 `THIRD_PARTY_AUTH_ERROR` (the APNs or web push credentials in the Firebase project):\n * `PermanentError`, devices kept.\n * - transient: 401 `UNAUTHENTICATED` (the cached access token is dropped first), 429\n * `QUOTA_EXCEEDED`, 503 `UNAVAILABLE`, 500 `INTERNAL` and any other 429 / 5xx.\n * - everything else (another 4xx) is this request itself: a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set(['UNREGISTERED', 'NOT_FOUND']);\n\nconst CONFIGURATION_CODES: ReadonlySet<string> = new Set([\n 'SENDER_ID_MISMATCH',\n 'THIRD_PARTY_AUTH_ERROR',\n]);\nconst TRANSIENT_CODES: ReadonlySet<string> = new Set(['QUOTA_EXCEEDED', 'UNAVAILABLE', 'INTERNAL']);\n\nconst SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';\n\n/**\n * The OAuth2 access token for one service account, exchanged once and reused until a minute\n * before it expires. Concurrent callers without a usable token share the one exchange in flight,\n * and an exchange that failed is not kept, so the next call tries again. One instance serves every\n * sender built on the account — a host keeps it across runs, and builds the senders per run.\n */\nexport class FcmAccessToken {\n /** The Firebase project the account belongs to — the `messages:send` URL names it. */\n readonly projectId: string;\n private minted: { value: string; expiresAt: number } | undefined;\n private minting: Promise<string> | undefined;\n\n constructor(private readonly options: FcmAccessTokenOptions) {\n this.projectId = options.serviceAccount.project_id;\n }\n\n /** The current token, exchanged now if there is none or it is about to expire. */\n async token(now = Date.now()): Promise<string> {\n if (this.minted !== undefined && this.minted.expiresAt - 60_000 > now) {\n return this.minted.value;\n }\n this.minting ??= this.exchange(now)\n .then((minted) => {\n this.minted = minted;\n return minted.value;\n })\n .finally(() => {\n this.minting = undefined;\n });\n return this.minting;\n }\n\n /** Forget the token: FCM refused it (401), so the next call exchanges a new one. */\n invalidate(): void {\n this.minted = undefined;\n }\n\n private async exchange(now: number): Promise<{ value: string; expiresAt: number }> {\n const { serviceAccount } = this.options;\n const tokenUri = serviceAccount.token_uri ?? 'https://oauth2.googleapis.com/token';\n const assertion = await signJwt(serviceAccount, tokenUri, SCOPE, now);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(tokenUri, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion,\n }),\n });\n if (!response.ok) {\n const detail = `${response.status} ${await response.text()}`;\n // A 4xx from the token endpoint (`invalid_grant`: the key was revoked, `invalid_client`)\n // is the service account itself; only a throttled or failing endpoint is worth a retry\n if (isRetryableStatus(response.status)) {\n throw new Error(`FCM token exchange failed: ${detail}`);\n }\n throw new PermanentError(\n `FCM token exchange refused for '${serviceAccount.client_email}': ${detail}`\n );\n }\n const json = (await response.json()) as { access_token: string; expires_in: number };\n return { value: json.access_token, expiresAt: now + json.expires_in * 1000 };\n }\n}\n\nexport class FcmPushSender implements MessageSender {\n constructor(private readonly options: FcmPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`FcmPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`FcmPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n const { credentials, android, apns } = this.options;\n const doFetch = this.options.fetch ?? fetch;\n\n const response = await doFetch(\n `https://fcm.googleapis.com/v1/projects/${credentials.projectId}/messages:send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${await credentials.token()}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n message: {\n token: message.recipient,\n notification: { title, body, ...(image === undefined ? {} : { image }) },\n // The template key travels as data so the app can route a tap; the\n // idempotency key lets the app de-duplicate a redelivered push.\n data: {\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n },\n ...(android === undefined ? {} : { android: { notification: android } }),\n apns: { payload: { aps: { sound: 'default', ...apns } } },\n },\n }),\n }\n );\n if (response.ok) return;\n\n const text = await response.text();\n const { status } = response;\n const code = fcmErrorCode(text);\n if (code === 'UNREGISTERED' || (status === 404 && DEAD_TOKEN_REASONS.has(code))) {\n // A dead token is a fact about the recipient, not a transient failure:\n // report it and move on, so the step does not retry into the same wall.\n console.warn(`[fcm] token gone for user ${message.userId}: ${code}`);\n return { delivered: false, reason: code };\n }\n if (code === 'INVALID_ARGUMENT') {\n // The token or our payload: settled so the report says which device, nothing deleted\n console.warn(`[fcm] refused for user ${message.userId}: ${status} ${text}`);\n return { delivered: false, reason: code };\n }\n if (CONFIGURATION_CODES.has(code)) {\n throw new PermanentError(\n `FCM refused the request for project '${credentials.projectId}': ${status} ${code}`\n );\n }\n if (status === 401 || code === 'UNAUTHENTICATED') {\n // The access token was revoked before it expired: mint a new one on the retry\n credentials.invalidate();\n throw new Error(\n `FCM send failed: ${status} ${code || text} (access token renewed for the retry)`\n );\n }\n if (TRANSIENT_CODES.has(code) || isRetryableStatus(status)) {\n throw new Error(`FCM send failed: ${status} ${code || text}`);\n }\n throw new PermanentError(`FCM refused the request: ${status} ${code || text}`);\n }\n}\n\n/** FCM v1 error bodies carry the code at `error.status` and, for messaging, in `error.details[].errorCode`. */\nexport function fcmErrorCode(body: string): string {\n try {\n const parsed = JSON.parse(body) as {\n error?: { status?: string; details?: { errorCode?: string }[] };\n };\n const detail = parsed.error?.details?.find((d) => d.errorCode !== undefined)?.errorCode;\n return detail ?? parsed.error?.status ?? '';\n } catch {\n return '';\n }\n}\n\n/** RS256-sign the OAuth2 JWT assertion for a service account. */\nexport async function signJwt(\n account: FcmServiceAccount,\n audience: string,\n scope: string,\n nowMs = Date.now()\n): Promise<string> {\n const iat = Math.floor(nowMs / 1000);\n const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));\n const claims = base64url(\n JSON.stringify({ iss: account.client_email, scope, aud: audience, iat, exp: iat + 3600 })\n );\n const key = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(account.private_key),\n { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },\n false,\n ['sign']\n );\n const signature = await crypto.subtle.sign(\n 'RSASSA-PKCS1-v1_5',\n key,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** Parse the service-account JSON as it is usually shipped: base64 of the file, in one secret. */\nexport function serviceAccountFromBase64(value: string): FcmServiceAccount {\n let json: Partial<FcmServiceAccount>;\n try {\n json = JSON.parse(atob(value)) as Partial<FcmServiceAccount>;\n } catch (error) {\n // A secret that is not base64 JSON is the same on every attempt: not worth a retry\n throw new PermanentError('service account is not base64-encoded JSON', { cause: error });\n }\n if (!json.project_id || !json.client_email || !json.private_key) {\n throw new PermanentError(\n 'service account JSON is missing project_id, client_email or private_key'\n );\n }\n return json as FcmServiceAccount;\n}\n"],"mappings":"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"}
package/dist/fcm.d.cts CHANGED
@@ -19,8 +19,14 @@ export interface FcmServiceAccount {
19
19
  private_key: string;
20
20
  token_uri?: string;
21
21
  }
22
- export interface FcmPushOptions {
22
+ export interface FcmAccessTokenOptions {
23
23
  serviceAccount: FcmServiceAccount;
24
+ /** Fetch implementation for the token exchange (defaults to the global one). */
25
+ fetch?: typeof fetch;
26
+ }
27
+ export interface FcmPushOptions {
28
+ /** The access token the requests carry — shared by every sender on the same service account. */
29
+ credentials: FcmAccessToken;
24
30
  render: PushRenderer;
25
31
  /** Android-specific delivery options, e.g. `{ channel_id: 'default' }` (FCM v1 `android.notification`). */
26
32
  android?: Record<string, unknown>;
@@ -28,15 +34,49 @@ export interface FcmPushOptions {
28
34
  apns?: Record<string, unknown>;
29
35
  /** Fetch implementation (defaults to the global one). */
30
36
  fetch?: typeof fetch;
31
- /** Called for a token FCM reports as gone (`UNREGISTERED`); `channelsFromConfig` removes the device. */
32
- onUnregistered?: (message: OutboundMessage) => Promise<void> | void;
37
+ }
38
+ /**
39
+ * How an FCM v1 answer is classified, after the API's error codes
40
+ * (https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode):
41
+ *
42
+ * - dead token: 404 `UNREGISTERED` (the app was uninstalled, or the token expired) — also a 404
43
+ * whose only code is the gRPC status `NOT_FOUND`. The copy settles undelivered and the host
44
+ * forgets the token.
45
+ * - undelivered, device kept: 400 `INVALID_ARGUMENT`. FCM gives it for a malformed token but
46
+ * just as well for a payload problem (a message too big, a bad data key, a bad TTL), which
47
+ * would hit every device of the template at once; the copy settles with the reason, so the
48
+ * report shows it, and nothing is deleted on a guess.
49
+ * - configuration: 403 `SENDER_ID_MISMATCH` (the token belongs to another Firebase project) and
50
+ * 401 `THIRD_PARTY_AUTH_ERROR` (the APNs or web push credentials in the Firebase project):
51
+ * `PermanentError`, devices kept.
52
+ * - transient: 401 `UNAUTHENTICATED` (the cached access token is dropped first), 429
53
+ * `QUOTA_EXCEEDED`, 503 `UNAVAILABLE`, 500 `INTERNAL` and any other 429 / 5xx.
54
+ * - everything else (another 4xx) is this request itself: a `PermanentError`.
55
+ */
56
+ export declare const DEAD_TOKEN_REASONS: ReadonlySet<string>;
57
+ /**
58
+ * The OAuth2 access token for one service account, exchanged once and reused until a minute
59
+ * before it expires. Concurrent callers without a usable token share the one exchange in flight,
60
+ * and an exchange that failed is not kept, so the next call tries again. One instance serves every
61
+ * sender built on the account — a host keeps it across runs, and builds the senders per run.
62
+ */
63
+ export declare class FcmAccessToken {
64
+ private readonly options;
65
+ /** The Firebase project the account belongs to — the `messages:send` URL names it. */
66
+ readonly projectId: string;
67
+ private minted;
68
+ private minting;
69
+ constructor(options: FcmAccessTokenOptions);
70
+ /** The current token, exchanged now if there is none or it is about to expire. */
71
+ token(now?: number): Promise<string>;
72
+ /** Forget the token: FCM refused it (401), so the next call exchanges a new one. */
73
+ invalidate(): void;
74
+ private exchange;
33
75
  }
34
76
  export declare class FcmPushSender implements MessageSender {
35
77
  private readonly options;
36
- private token;
37
78
  constructor(options: FcmPushOptions);
38
79
  send(message: OutboundMessage): Promise<void | SendResult>;
39
- private accessToken;
40
80
  }
41
81
  /** FCM v1 error bodies carry the code at `error.status` and, for messaging, in `error.details[].errorCode`. */
42
82
  export declare function fcmErrorCode(body: string): string;
@@ -1 +1 @@
1
- {"version":3,"file":"fcm.d.cts","names":[],"sources":["../src/fcm.ts"],"mappings":";;;;;;;;;;;;;;iBAeiB;EACf;EACA;;EAEA;EACA;;iBAGe;EACf,gBAAgB;EAChB,QAAQ;;EAER,UAAU;;EAEV,OAAO;;EAEP,eAAe;;EAEf,kBAAkB,SAAS,oBAAoB;;qBAQpC,yBAAyB;mBAGP;UAFrB;EAER,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;UAkDvC;;;wBA0BA,aAAa;;wBAaP,QACpB,SAAS,mBACT,kBACA,eACA,iBACC;;wBAsBa,yBAAyB,gBAAgB"}
1
+ {"version":3,"file":"fcm.d.cts","names":[],"sources":["../src/fcm.ts"],"mappings":";;;;;;;;;;;;;;iBAgBiB;EACf;EACA;;EAEA;EACA;;iBAGe;EACf,gBAAgB;;EAEhB,eAAe;;iBAGA;;EAEf,aAAa;EACb,QAAQ;;EAER,UAAU;;EAEV,OAAO;;EAEP,eAAe;;;;;;;;;;;;;;;;;;;;qBAqBJ,oBAAoB;;;;;;;qBAgBpB;mBAMkB;;WAJpB;UACD;UACA;EAER,YAA6B,SAAS;;EAKhC,MAAM,eAAmB;;EAgB/B;UAIc;;qBA6BH,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;wBAyEvC,aAAa;;wBAaP,QACpB,SAAS,mBACT,kBACA,eACA,iBACC;;wBAsBa,yBAAyB,gBAAgB"}
package/dist/fcm.d.mts CHANGED
@@ -19,8 +19,14 @@ export interface FcmServiceAccount {
19
19
  private_key: string;
20
20
  token_uri?: string;
21
21
  }
22
- export interface FcmPushOptions {
22
+ export interface FcmAccessTokenOptions {
23
23
  serviceAccount: FcmServiceAccount;
24
+ /** Fetch implementation for the token exchange (defaults to the global one). */
25
+ fetch?: typeof fetch;
26
+ }
27
+ export interface FcmPushOptions {
28
+ /** The access token the requests carry — shared by every sender on the same service account. */
29
+ credentials: FcmAccessToken;
24
30
  render: PushRenderer;
25
31
  /** Android-specific delivery options, e.g. `{ channel_id: 'default' }` (FCM v1 `android.notification`). */
26
32
  android?: Record<string, unknown>;
@@ -28,15 +34,49 @@ export interface FcmPushOptions {
28
34
  apns?: Record<string, unknown>;
29
35
  /** Fetch implementation (defaults to the global one). */
30
36
  fetch?: typeof fetch;
31
- /** Called for a token FCM reports as gone (`UNREGISTERED`); `channelsFromConfig` removes the device. */
32
- onUnregistered?: (message: OutboundMessage) => Promise<void> | void;
37
+ }
38
+ /**
39
+ * How an FCM v1 answer is classified, after the API's error codes
40
+ * (https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode):
41
+ *
42
+ * - dead token: 404 `UNREGISTERED` (the app was uninstalled, or the token expired) — also a 404
43
+ * whose only code is the gRPC status `NOT_FOUND`. The copy settles undelivered and the host
44
+ * forgets the token.
45
+ * - undelivered, device kept: 400 `INVALID_ARGUMENT`. FCM gives it for a malformed token but
46
+ * just as well for a payload problem (a message too big, a bad data key, a bad TTL), which
47
+ * would hit every device of the template at once; the copy settles with the reason, so the
48
+ * report shows it, and nothing is deleted on a guess.
49
+ * - configuration: 403 `SENDER_ID_MISMATCH` (the token belongs to another Firebase project) and
50
+ * 401 `THIRD_PARTY_AUTH_ERROR` (the APNs or web push credentials in the Firebase project):
51
+ * `PermanentError`, devices kept.
52
+ * - transient: 401 `UNAUTHENTICATED` (the cached access token is dropped first), 429
53
+ * `QUOTA_EXCEEDED`, 503 `UNAVAILABLE`, 500 `INTERNAL` and any other 429 / 5xx.
54
+ * - everything else (another 4xx) is this request itself: a `PermanentError`.
55
+ */
56
+ export declare const DEAD_TOKEN_REASONS: ReadonlySet<string>;
57
+ /**
58
+ * The OAuth2 access token for one service account, exchanged once and reused until a minute
59
+ * before it expires. Concurrent callers without a usable token share the one exchange in flight,
60
+ * and an exchange that failed is not kept, so the next call tries again. One instance serves every
61
+ * sender built on the account — a host keeps it across runs, and builds the senders per run.
62
+ */
63
+ export declare class FcmAccessToken {
64
+ private readonly options;
65
+ /** The Firebase project the account belongs to — the `messages:send` URL names it. */
66
+ readonly projectId: string;
67
+ private minted;
68
+ private minting;
69
+ constructor(options: FcmAccessTokenOptions);
70
+ /** The current token, exchanged now if there is none or it is about to expire. */
71
+ token(now?: number): Promise<string>;
72
+ /** Forget the token: FCM refused it (401), so the next call exchanges a new one. */
73
+ invalidate(): void;
74
+ private exchange;
33
75
  }
34
76
  export declare class FcmPushSender implements MessageSender {
35
77
  private readonly options;
36
- private token;
37
78
  constructor(options: FcmPushOptions);
38
79
  send(message: OutboundMessage): Promise<void | SendResult>;
39
- private accessToken;
40
80
  }
41
81
  /** FCM v1 error bodies carry the code at `error.status` and, for messaging, in `error.details[].errorCode`. */
42
82
  export declare function fcmErrorCode(body: string): string;
@@ -1 +1 @@
1
- {"version":3,"file":"fcm.d.mts","names":[],"sources":["../src/fcm.ts"],"mappings":";;;;;;;;;;;;;;iBAeiB;EACf;EACA;;EAEA;EACA;;iBAGe;EACf,gBAAgB;EAChB,QAAQ;;EAER,UAAU;;EAEV,OAAO;;EAEP,eAAe;;EAEf,kBAAkB,SAAS,oBAAoB;;qBAQpC,yBAAyB;mBAGP;UAFrB;EAER,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;UAkDvC;;;wBA0BA,aAAa;;wBAaP,QACpB,SAAS,mBACT,kBACA,eACA,iBACC;;wBAsBa,yBAAyB,gBAAgB"}
1
+ {"version":3,"file":"fcm.d.mts","names":[],"sources":["../src/fcm.ts"],"mappings":";;;;;;;;;;;;;;iBAgBiB;EACf;EACA;;EAEA;EACA;;iBAGe;EACf,gBAAgB;;EAEhB,eAAe;;iBAGA;;EAEf,aAAa;EACb,QAAQ;;EAER,UAAU;;EAEV,OAAO;;EAEP,eAAe;;;;;;;;;;;;;;;;;;;;qBAqBJ,oBAAoB;;;;;;;qBAgBpB;mBAMkB;;WAJpB;UACD;UACA;EAER,YAA6B,SAAS;;EAKhC,MAAM,eAAmB;;EAgB/B;UAIc;;qBA6BH,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;wBAyEvC,aAAa;;wBAaP,QACpB,SAAS,mBACT,kBACA,eACA,iBACC;;wBAsBa,yBAAyB,gBAAgB"}
package/dist/fcm.mjs CHANGED
@@ -1,15 +1,89 @@
1
+ import { PermanentError, isRetryableStatus } from "./engine/retry.mjs";
1
2
  import { base64url, pemToDer } from "./jwt.mjs";
2
3
  //#region src/fcm.ts
3
- /** Errors FCM reports for a token that will never deliver again — not worth a retry. */
4
- const DEAD_TOKEN_CODES = /* @__PURE__ */ new Set([
5
- "UNREGISTERED",
6
- "INVALID_ARGUMENT",
7
- "SENDER_ID_MISMATCH"
4
+ /**
5
+ * How an FCM v1 answer is classified, after the API's error codes
6
+ * (https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode):
7
+ *
8
+ * - dead token: 404 `UNREGISTERED` (the app was uninstalled, or the token expired) — also a 404
9
+ * whose only code is the gRPC status `NOT_FOUND`. The copy settles undelivered and the host
10
+ * forgets the token.
11
+ * - undelivered, device kept: 400 `INVALID_ARGUMENT`. FCM gives it for a malformed token but
12
+ * just as well for a payload problem (a message too big, a bad data key, a bad TTL), which
13
+ * would hit every device of the template at once; the copy settles with the reason, so the
14
+ * report shows it, and nothing is deleted on a guess.
15
+ * - configuration: 403 `SENDER_ID_MISMATCH` (the token belongs to another Firebase project) and
16
+ * 401 `THIRD_PARTY_AUTH_ERROR` (the APNs or web push credentials in the Firebase project):
17
+ * `PermanentError`, devices kept.
18
+ * - transient: 401 `UNAUTHENTICATED` (the cached access token is dropped first), 429
19
+ * `QUOTA_EXCEEDED`, 503 `UNAVAILABLE`, 500 `INTERNAL` and any other 429 / 5xx.
20
+ * - everything else (another 4xx) is this request itself: a `PermanentError`.
21
+ */
22
+ const DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set(["UNREGISTERED", "NOT_FOUND"]);
23
+ const CONFIGURATION_CODES = /* @__PURE__ */ new Set(["SENDER_ID_MISMATCH", "THIRD_PARTY_AUTH_ERROR"]);
24
+ const TRANSIENT_CODES = /* @__PURE__ */ new Set([
25
+ "QUOTA_EXCEEDED",
26
+ "UNAVAILABLE",
27
+ "INTERNAL"
8
28
  ]);
9
29
  const SCOPE = "https://www.googleapis.com/auth/firebase.messaging";
30
+ /**
31
+ * The OAuth2 access token for one service account, exchanged once and reused until a minute
32
+ * before it expires. Concurrent callers without a usable token share the one exchange in flight,
33
+ * and an exchange that failed is not kept, so the next call tries again. One instance serves every
34
+ * sender built on the account — a host keeps it across runs, and builds the senders per run.
35
+ */
36
+ var FcmAccessToken = class {
37
+ options;
38
+ /** The Firebase project the account belongs to — the `messages:send` URL names it. */
39
+ projectId;
40
+ minted;
41
+ minting;
42
+ constructor(options) {
43
+ this.options = options;
44
+ this.projectId = options.serviceAccount.project_id;
45
+ }
46
+ /** The current token, exchanged now if there is none or it is about to expire. */
47
+ async token(now = Date.now()) {
48
+ if (this.minted !== void 0 && this.minted.expiresAt - 6e4 > now) return this.minted.value;
49
+ this.minting ??= this.exchange(now).then((minted) => {
50
+ this.minted = minted;
51
+ return minted.value;
52
+ }).finally(() => {
53
+ this.minting = void 0;
54
+ });
55
+ return this.minting;
56
+ }
57
+ /** Forget the token: FCM refused it (401), so the next call exchanges a new one. */
58
+ invalidate() {
59
+ this.minted = void 0;
60
+ }
61
+ async exchange(now) {
62
+ const { serviceAccount } = this.options;
63
+ const tokenUri = serviceAccount.token_uri ?? "https://oauth2.googleapis.com/token";
64
+ const assertion = await signJwt(serviceAccount, tokenUri, SCOPE, now);
65
+ const response = await (this.options.fetch ?? fetch)(tokenUri, {
66
+ method: "POST",
67
+ headers: { "content-type": "application/x-www-form-urlencoded" },
68
+ body: new URLSearchParams({
69
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
70
+ assertion
71
+ })
72
+ });
73
+ if (!response.ok) {
74
+ const detail = `${response.status} ${await response.text()}`;
75
+ if (isRetryableStatus(response.status)) throw new Error(`FCM token exchange failed: ${detail}`);
76
+ throw new PermanentError(`FCM token exchange refused for '${serviceAccount.client_email}': ${detail}`);
77
+ }
78
+ const json = await response.json();
79
+ return {
80
+ value: json.access_token,
81
+ expiresAt: now + json.expires_in * 1e3
82
+ };
83
+ }
84
+ };
10
85
  var FcmPushSender = class {
11
86
  options;
12
- token;
13
87
  constructor(options) {
14
88
  this.options = options;
15
89
  }
@@ -17,11 +91,11 @@ var FcmPushSender = class {
17
91
  if (message.channel !== "push") throw new Error(`FcmPushSender: unsupported channel '${message.channel}'`);
18
92
  if (message.recipient === void 0) throw new Error(`FcmPushSender: no push token for user '${message.userId}'`);
19
93
  const { title, body, image, url } = await this.options.render(message.template, message.props);
20
- const { serviceAccount, android, apns } = this.options;
21
- const response = await (this.options.fetch ?? fetch)(`https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`, {
94
+ const { credentials, android, apns } = this.options;
95
+ const response = await (this.options.fetch ?? fetch)(`https://fcm.googleapis.com/v1/projects/${credentials.projectId}/messages:send`, {
22
96
  method: "POST",
23
97
  headers: {
24
- authorization: `Bearer ${await this.accessToken()}`,
98
+ authorization: `Bearer ${await credentials.token()}`,
25
99
  "content-type": "application/json"
26
100
  },
27
101
  body: JSON.stringify({ message: {
@@ -46,37 +120,29 @@ var FcmPushSender = class {
46
120
  });
47
121
  if (response.ok) return;
48
122
  const text = await response.text();
49
- if (DEAD_TOKEN_CODES.has(fcmErrorCode(text))) {
50
- console.warn(`[fcm] token gone for user ${message.userId}: ${fcmErrorCode(text)}`);
51
- await this.options.onUnregistered?.(message);
123
+ const { status } = response;
124
+ const code = fcmErrorCode(text);
125
+ if (code === "UNREGISTERED" || status === 404 && DEAD_TOKEN_REASONS.has(code)) {
126
+ console.warn(`[fcm] token gone for user ${message.userId}: ${code}`);
52
127
  return {
53
128
  delivered: false,
54
- reason: fcmErrorCode(text)
129
+ reason: code
55
130
  };
56
131
  }
57
- throw new Error(`FCM send failed: ${response.status} ${text}`);
58
- }
59
- async accessToken() {
60
- const now = Date.now();
61
- if (this.token !== void 0 && this.token.expiresAt - 6e4 > now) return this.token.value;
62
- const { serviceAccount } = this.options;
63
- const tokenUri = serviceAccount.token_uri ?? "https://oauth2.googleapis.com/token";
64
- const assertion = await signJwt(serviceAccount, tokenUri, SCOPE, now);
65
- const response = await (this.options.fetch ?? fetch)(tokenUri, {
66
- method: "POST",
67
- headers: { "content-type": "application/x-www-form-urlencoded" },
68
- body: new URLSearchParams({
69
- grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
70
- assertion
71
- })
72
- });
73
- if (!response.ok) throw new Error(`FCM token exchange failed: ${response.status} ${await response.text()}`);
74
- const json = await response.json();
75
- this.token = {
76
- value: json.access_token,
77
- expiresAt: now + json.expires_in * 1e3
78
- };
79
- return json.access_token;
132
+ if (code === "INVALID_ARGUMENT") {
133
+ console.warn(`[fcm] refused for user ${message.userId}: ${status} ${text}`);
134
+ return {
135
+ delivered: false,
136
+ reason: code
137
+ };
138
+ }
139
+ if (CONFIGURATION_CODES.has(code)) throw new PermanentError(`FCM refused the request for project '${credentials.projectId}': ${status} ${code}`);
140
+ if (status === 401 || code === "UNAUTHENTICATED") {
141
+ credentials.invalidate();
142
+ throw new Error(`FCM send failed: ${status} ${code || text} (access token renewed for the retry)`);
143
+ }
144
+ if (TRANSIENT_CODES.has(code) || isRetryableStatus(status)) throw new Error(`FCM send failed: ${status} ${code || text}`);
145
+ throw new PermanentError(`FCM refused the request: ${status} ${code || text}`);
80
146
  }
81
147
  };
82
148
  /** FCM v1 error bodies carry the code at `error.status` and, for messaging, in `error.details[].errorCode`. */
@@ -111,11 +177,16 @@ async function signJwt(account, audience, scope, nowMs = Date.now()) {
111
177
  }
112
178
  /** Parse the service-account JSON as it is usually shipped: base64 of the file, in one secret. */
113
179
  function serviceAccountFromBase64(value) {
114
- const json = JSON.parse(atob(value));
115
- if (!json.project_id || !json.client_email || !json.private_key) throw new Error("service account JSON is missing project_id, client_email or private_key");
180
+ let json;
181
+ try {
182
+ json = JSON.parse(atob(value));
183
+ } catch (error) {
184
+ throw new PermanentError("service account is not base64-encoded JSON", { cause: error });
185
+ }
186
+ if (!json.project_id || !json.client_email || !json.private_key) throw new PermanentError("service account JSON is missing project_id, client_email or private_key");
116
187
  return json;
117
188
  }
118
189
  //#endregion
119
- export { FcmPushSender, fcmErrorCode, serviceAccountFromBase64, signJwt };
190
+ export { DEAD_TOKEN_REASONS, FcmAccessToken, FcmPushSender, fcmErrorCode, serviceAccountFromBase64, signJwt };
120
191
 
121
192
  //# sourceMappingURL=fcm.mjs.map