@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.
- package/README.md +230 -40
- package/dist/apns.cjs +113 -26
- package/dist/apns.cjs.map +1 -1
- package/dist/apns.d.cts +52 -6
- package/dist/apns.d.cts.map +1 -1
- package/dist/apns.d.mts +52 -6
- package/dist/apns.d.mts.map +1 -1
- package/dist/apns.mjs +111 -27
- package/dist/apns.mjs.map +1 -1
- package/dist/aws/engine-step.cjs +5 -5
- package/dist/aws/engine-step.cjs.map +1 -1
- package/dist/aws/engine-step.d.cts +8 -2
- package/dist/aws/engine-step.d.cts.map +1 -1
- package/dist/aws/engine-step.d.mts +8 -2
- package/dist/aws/engine-step.d.mts.map +1 -1
- package/dist/aws/engine-step.mjs +5 -5
- package/dist/aws/engine-step.mjs.map +1 -1
- package/dist/aws/index.cjs +50 -6
- package/dist/aws/index.cjs.map +1 -1
- package/dist/aws/index.d.cts +4 -3
- package/dist/aws/index.d.cts.map +1 -1
- package/dist/aws/index.d.mts +4 -3
- package/dist/aws/index.d.mts.map +1 -1
- package/dist/aws/index.mjs +49 -7
- package/dist/aws/index.mjs.map +1 -1
- package/dist/aws/instances.cjs +29 -6
- package/dist/aws/instances.cjs.map +1 -1
- package/dist/aws/instances.d.cts +11 -1
- package/dist/aws/instances.d.cts.map +1 -1
- package/dist/aws/instances.d.mts +11 -1
- package/dist/aws/instances.d.mts.map +1 -1
- package/dist/aws/instances.mjs +27 -4
- package/dist/aws/instances.mjs.map +1 -1
- package/dist/client.cjs +4 -1
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +2 -2
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +2 -2
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +4 -1
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/bindings.cjs +28 -25
- package/dist/cloudflare/bindings.cjs.map +1 -1
- package/dist/cloudflare/bindings.d.cts +15 -129
- package/dist/cloudflare/bindings.d.cts.map +1 -1
- package/dist/cloudflare/bindings.d.mts +15 -129
- package/dist/cloudflare/bindings.d.mts.map +1 -1
- package/dist/cloudflare/bindings.mjs +28 -23
- package/dist/cloudflare/bindings.mjs.map +1 -1
- package/dist/cloudflare/d1-env.cjs +6 -6
- package/dist/cloudflare/d1-env.cjs.map +1 -1
- package/dist/cloudflare/d1-env.d.cts +5 -5
- package/dist/cloudflare/d1-env.d.mts +5 -5
- package/dist/cloudflare/d1-env.mjs +6 -6
- package/dist/cloudflare/d1-env.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +29 -18
- package/dist/cloudflare/index.d.cts +8 -4
- package/dist/cloudflare/index.d.mts +8 -4
- package/dist/cloudflare/index.mjs +9 -5
- package/dist/cloudflare/runner.cjs +29 -14
- package/dist/cloudflare/runner.cjs.map +1 -1
- package/dist/cloudflare/runner.d.cts +9 -2
- package/dist/cloudflare/runner.d.cts.map +1 -1
- package/dist/cloudflare/runner.d.mts +9 -2
- package/dist/cloudflare/runner.d.mts.map +1 -1
- package/dist/cloudflare/runner.mjs +28 -13
- package/dist/cloudflare/runner.mjs.map +1 -1
- package/dist/cloudflare/schema.sql +27 -6
- package/dist/cloudflare/worker.cjs +19 -9
- package/dist/cloudflare/worker.cjs.map +1 -1
- package/dist/cloudflare/worker.d.cts +2 -2
- package/dist/cloudflare/worker.d.cts.map +1 -1
- package/dist/cloudflare/worker.d.mts +2 -2
- package/dist/cloudflare/worker.d.mts.map +1 -1
- package/dist/cloudflare/worker.mjs +19 -9
- package/dist/cloudflare/worker.mjs.map +1 -1
- package/dist/config.cjs +21 -5
- package/dist/config.cjs.map +1 -1
- package/dist/config.mjs +21 -5
- package/dist/config.mjs.map +1 -1
- package/dist/dsl/action.cjs +3 -6
- package/dist/dsl/action.cjs.map +1 -1
- package/dist/dsl/action.d.cts +12 -11
- package/dist/dsl/action.d.cts.map +1 -1
- package/dist/dsl/action.d.mts +12 -11
- package/dist/dsl/action.d.mts.map +1 -1
- package/dist/dsl/action.mjs +3 -6
- package/dist/dsl/action.mjs.map +1 -1
- package/dist/dsl/bundle.cjs +35 -31
- package/dist/dsl/bundle.cjs.map +1 -1
- package/dist/dsl/bundle.d.cts.map +1 -1
- package/dist/dsl/bundle.d.mts.map +1 -1
- package/dist/dsl/bundle.mjs +35 -31
- package/dist/dsl/bundle.mjs.map +1 -1
- package/dist/dsl/condition.cjs +13 -1
- package/dist/dsl/condition.cjs.map +1 -1
- package/dist/dsl/condition.d.cts +9 -1
- package/dist/dsl/condition.d.cts.map +1 -1
- package/dist/dsl/condition.d.mts +9 -1
- package/dist/dsl/condition.d.mts.map +1 -1
- package/dist/dsl/condition.mjs +13 -2
- package/dist/dsl/condition.mjs.map +1 -1
- package/dist/dsl/flow.cjs +33 -11
- package/dist/dsl/flow.cjs.map +1 -1
- package/dist/dsl/flow.d.cts +9 -1
- package/dist/dsl/flow.d.cts.map +1 -1
- package/dist/dsl/flow.d.mts +9 -1
- package/dist/dsl/flow.d.mts.map +1 -1
- package/dist/dsl/flow.mjs +34 -13
- package/dist/dsl/flow.mjs.map +1 -1
- package/dist/dsl/workflow.cjs +2 -3
- package/dist/dsl/workflow.cjs.map +1 -1
- package/dist/dsl/workflow.d.cts.map +1 -1
- package/dist/dsl/workflow.d.mts.map +1 -1
- package/dist/dsl/workflow.mjs +3 -4
- package/dist/dsl/workflow.mjs.map +1 -1
- package/dist/engine/actions.cjs +1 -8
- package/dist/engine/actions.cjs.map +1 -1
- package/dist/engine/actions.d.cts +1 -12
- package/dist/engine/actions.d.cts.map +1 -1
- package/dist/engine/actions.d.mts +1 -12
- package/dist/engine/actions.d.mts.map +1 -1
- package/dist/engine/actions.mjs +1 -8
- package/dist/engine/actions.mjs.map +1 -1
- package/dist/engine/condition.cjs +25 -5
- package/dist/engine/condition.cjs.map +1 -1
- package/dist/engine/condition.d.cts +12 -3
- package/dist/engine/condition.d.cts.map +1 -1
- package/dist/engine/condition.d.mts +12 -3
- package/dist/engine/condition.d.mts.map +1 -1
- package/dist/engine/condition.mjs +25 -6
- package/dist/engine/condition.mjs.map +1 -1
- package/dist/engine/index.cjs +3 -0
- package/dist/engine/index.d.cts +5 -5
- package/dist/engine/index.d.mts +5 -5
- package/dist/engine/index.mjs +4 -4
- package/dist/engine/interpreter.cjs +27 -20
- package/dist/engine/interpreter.cjs.map +1 -1
- package/dist/engine/interpreter.d.cts.map +1 -1
- package/dist/engine/interpreter.d.mts.map +1 -1
- package/dist/engine/interpreter.mjs +27 -20
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +15 -6
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +15 -6
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/engine/retry.cjs +13 -0
- package/dist/engine/retry.cjs.map +1 -1
- package/dist/engine/retry.d.cts +12 -0
- package/dist/engine/retry.d.cts.map +1 -1
- package/dist/engine/retry.d.mts +12 -0
- package/dist/engine/retry.d.mts.map +1 -1
- package/dist/engine/retry.mjs +13 -1
- package/dist/engine/retry.mjs.map +1 -1
- package/dist/engine/time-scale.cjs +90 -15
- package/dist/engine/time-scale.cjs.map +1 -1
- package/dist/engine/time-scale.d.cts +9 -6
- package/dist/engine/time-scale.d.cts.map +1 -1
- package/dist/engine/time-scale.d.mts +9 -6
- package/dist/engine/time-scale.d.mts.map +1 -1
- package/dist/engine/time-scale.mjs +90 -16
- package/dist/engine/time-scale.mjs.map +1 -1
- package/dist/fcm.cjs +111 -38
- package/dist/fcm.cjs.map +1 -1
- package/dist/fcm.d.cts +45 -5
- package/dist/fcm.d.cts.map +1 -1
- package/dist/fcm.d.mts +45 -5
- package/dist/fcm.d.mts.map +1 -1
- package/dist/fcm.mjs +110 -39
- package/dist/fcm.mjs.map +1 -1
- package/dist/hash.cjs +6 -6
- package/dist/hash.cjs.map +1 -1
- package/dist/hash.mjs +6 -6
- package/dist/hash.mjs.map +1 -1
- package/dist/host/auth.cjs +49 -0
- package/dist/host/auth.cjs.map +1 -0
- package/dist/host/auth.d.cts +35 -0
- package/dist/host/auth.d.cts.map +1 -0
- package/dist/host/auth.d.mts +35 -0
- package/dist/host/auth.d.mts.map +1 -0
- package/dist/host/auth.mjs +46 -0
- package/dist/host/auth.mjs.map +1 -0
- package/dist/host/channels.cjs +118 -40
- package/dist/host/channels.cjs.map +1 -1
- package/dist/host/channels.d.cts +27 -1
- package/dist/host/channels.d.cts.map +1 -1
- package/dist/host/channels.d.mts +27 -1
- package/dist/host/channels.d.mts.map +1 -1
- package/dist/host/channels.mjs +121 -44
- package/dist/host/channels.mjs.map +1 -1
- package/dist/host/execute.cjs +14 -2
- package/dist/host/execute.cjs.map +1 -1
- package/dist/host/execute.d.cts +4 -4
- package/dist/host/execute.d.cts.map +1 -1
- package/dist/host/execute.d.mts +4 -4
- package/dist/host/execute.d.mts.map +1 -1
- package/dist/host/execute.mjs +13 -1
- package/dist/host/execute.mjs.map +1 -1
- package/dist/host/fresh-read.cjs +30 -0
- package/dist/host/fresh-read.cjs.map +1 -0
- package/dist/host/fresh-read.d.cts +18 -0
- package/dist/host/fresh-read.d.cts.map +1 -0
- package/dist/host/fresh-read.d.mts +18 -0
- package/dist/host/fresh-read.d.mts.map +1 -0
- package/dist/host/fresh-read.mjs +29 -0
- package/dist/host/fresh-read.mjs.map +1 -0
- package/dist/host/http.cjs +340 -0
- package/dist/host/http.cjs.map +1 -0
- package/dist/host/http.d.cts +58 -0
- package/dist/host/http.d.cts.map +1 -0
- package/dist/host/http.d.mts +58 -0
- package/dist/host/http.d.mts.map +1 -0
- package/dist/host/http.mjs +336 -0
- package/dist/host/http.mjs.map +1 -0
- package/dist/host/{router.cjs → ingest.cjs} +147 -175
- package/dist/host/ingest.cjs.map +1 -0
- package/dist/host/{router.d.cts → ingest.d.cts} +48 -30
- package/dist/host/ingest.d.cts.map +1 -0
- package/dist/host/{router.d.mts → ingest.d.mts} +48 -30
- package/dist/host/ingest.d.mts.map +1 -0
- package/dist/host/{router.mjs → ingest.mjs} +144 -173
- package/dist/host/ingest.mjs.map +1 -0
- package/dist/host/journey-host.cjs +66 -93
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts +31 -26
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts +31 -26
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +66 -93
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/host/platform.cjs +21 -0
- package/dist/host/platform.cjs.map +1 -0
- package/dist/host/platform.d.cts +126 -0
- package/dist/host/platform.d.cts.map +1 -0
- package/dist/host/platform.d.mts +126 -0
- package/dist/host/platform.d.mts.map +1 -0
- package/dist/host/platform.mjs +19 -0
- package/dist/host/platform.mjs.map +1 -0
- package/dist/host/senders.cjs +18 -9
- package/dist/host/senders.cjs.map +1 -1
- package/dist/host/senders.d.cts +9 -4
- package/dist/host/senders.d.cts.map +1 -1
- package/dist/host/senders.d.mts +9 -4
- package/dist/host/senders.d.mts.map +1 -1
- package/dist/host/senders.mjs +18 -9
- package/dist/host/senders.mjs.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/dist/ir.cjs +5 -7
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +3 -12
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +3 -12
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +5 -7
- package/dist/ir.mjs.map +1 -1
- package/dist/plan.cjs +27 -33
- package/dist/plan.cjs.map +1 -1
- package/dist/plan.d.cts +1 -1
- package/dist/plan.d.cts.map +1 -1
- package/dist/plan.d.mts +1 -1
- package/dist/plan.d.mts.map +1 -1
- package/dist/plan.mjs +27 -33
- package/dist/plan.mjs.map +1 -1
- package/dist/reporting/tracking.cjs +14 -4
- package/dist/reporting/tracking.cjs.map +1 -1
- package/dist/reporting/tracking.d.cts +10 -10
- package/dist/reporting/tracking.d.cts.map +1 -1
- package/dist/reporting/tracking.d.mts +10 -10
- package/dist/reporting/tracking.d.mts.map +1 -1
- package/dist/reporting/tracking.mjs +14 -4
- package/dist/reporting/tracking.mjs.map +1 -1
- package/dist/store/d1.cjs +88 -29
- package/dist/store/d1.cjs.map +1 -1
- package/dist/store/d1.d.cts +29 -11
- package/dist/store/d1.d.cts.map +1 -1
- package/dist/store/d1.d.mts +29 -11
- package/dist/store/d1.d.mts.map +1 -1
- package/dist/store/d1.mjs +88 -29
- package/dist/store/d1.mjs.map +1 -1
- package/dist/store/erasure.cjs +22 -0
- package/dist/store/erasure.cjs.map +1 -0
- package/dist/store/erasure.d.cts +18 -0
- package/dist/store/erasure.d.cts.map +1 -0
- package/dist/store/erasure.d.mts +18 -0
- package/dist/store/erasure.d.mts.map +1 -0
- package/dist/store/erasure.mjs +21 -0
- package/dist/store/erasure.mjs.map +1 -0
- package/dist/store/index.cjs +2 -0
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +67 -13
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +67 -13
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs +2 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres-reporting.cjs +125 -62
- package/dist/store/postgres-reporting.cjs.map +1 -1
- package/dist/store/postgres-reporting.d.cts +30 -2
- package/dist/store/postgres-reporting.d.cts.map +1 -1
- package/dist/store/postgres-reporting.d.mts +30 -2
- package/dist/store/postgres-reporting.d.mts.map +1 -1
- package/dist/store/postgres-reporting.mjs +125 -62
- package/dist/store/postgres-reporting.mjs.map +1 -1
- package/dist/store/postgres.cjs +179 -60
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +44 -7
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +44 -7
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +179 -60
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +7 -5
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +7 -5
- package/dist/store/tables.mjs.map +1 -1
- package/dist/store/user-lock.cjs +43 -0
- package/dist/store/user-lock.cjs.map +1 -0
- package/dist/store/user-lock.d.cts +32 -0
- package/dist/store/user-lock.d.cts.map +1 -0
- package/dist/store/user-lock.d.mts +32 -0
- package/dist/store/user-lock.d.mts.map +1 -0
- package/dist/store/user-lock.mjs +40 -0
- package/dist/store/user-lock.mjs.map +1 -0
- package/dist/transports/cloudflare-email.cjs +1 -1
- package/dist/transports/cloudflare-email.mjs +1 -1
- package/dist/transports/ses.cjs +47 -6
- package/dist/transports/ses.cjs.map +1 -1
- package/dist/transports/ses.d.cts +9 -1
- package/dist/transports/ses.d.cts.map +1 -1
- package/dist/transports/ses.d.mts +9 -1
- package/dist/transports/ses.d.mts.map +1 -1
- package/dist/transports/ses.mjs +47 -7
- package/dist/transports/ses.mjs.map +1 -1
- package/dist/webpush.cjs +133 -47
- package/dist/webpush.cjs.map +1 -1
- package/dist/webpush.d.cts +49 -8
- package/dist/webpush.d.cts.map +1 -1
- package/dist/webpush.d.mts +49 -8
- package/dist/webpush.d.mts.map +1 -1
- package/dist/webpush.mjs +130 -47
- package/dist/webpush.mjs.map +1 -1
- package/package.json +1 -1
- package/src/cloudflare/schema.sql +27 -6
- package/dist/host/router.cjs.map +0 -1
- package/dist/host/router.d.cts.map +0 -1
- package/dist/host/router.d.mts.map +0 -1
- 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
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 {
|
|
22
|
-
const response = await (this.options.fetch ?? fetch)(`https://fcm.googleapis.com/v1/projects/${
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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:
|
|
130
|
+
reason: code
|
|
56
131
|
};
|
|
57
132
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
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;
|
package/dist/fcm.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fcm.d.cts","names":[],"sources":["../src/fcm.ts"],"mappings":";;;;;;;;;;;;;;
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
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;
|
package/dist/fcm.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fcm.d.mts","names":[],"sources":["../src/fcm.ts"],"mappings":";;;;;;;;;;;;;;
|
|
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
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 {
|
|
21
|
-
const response = await (this.options.fetch ?? fetch)(`https://fcm.googleapis.com/v1/projects/${
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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:
|
|
129
|
+
reason: code
|
|
55
130
|
};
|
|
56
131
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|