@shware/workflow 3.2.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.
- package/README.md +100 -29
- 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 +47 -6
- package/dist/aws/index.cjs.map +1 -1
- package/dist/aws/index.d.cts +2 -2
- package/dist/aws/index.d.cts.map +1 -1
- package/dist/aws/index.d.mts +2 -2
- package/dist/aws/index.d.mts.map +1 -1
- package/dist/aws/index.mjs +47 -6
- 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 +17 -129
- package/dist/cloudflare/bindings.d.cts.map +1 -1
- package/dist/cloudflare/bindings.d.mts +17 -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 +28 -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/email-sender.cjs +24 -0
- package/dist/email-sender.cjs.map +1 -1
- package/dist/email-sender.d.cts +18 -0
- package/dist/email-sender.d.cts.map +1 -1
- package/dist/email-sender.d.mts +18 -0
- package/dist/email-sender.d.mts.map +1 -1
- package/dist/email-sender.mjs +24 -1
- package/dist/email-sender.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 +21 -6
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +21 -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 +21 -0
- package/dist/host/auth.d.cts.map +1 -0
- package/dist/host/auth.d.mts +21 -0
- package/dist/host/auth.d.mts.map +1 -0
- package/dist/host/auth.mjs +47 -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 +321 -0
- package/dist/host/http.cjs.map +1 -0
- package/dist/host/http.d.cts +54 -0
- package/dist/host/http.d.cts.map +1 -0
- package/dist/host/http.d.mts +54 -0
- package/dist/host/http.d.mts.map +1 -0
- package/dist/host/http.mjs +317 -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 +65 -93
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts +19 -26
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts +19 -26
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +65 -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/observer.cjs +1 -0
- package/dist/reporting/observer.cjs.map +1 -1
- package/dist/reporting/observer.mjs +1 -0
- package/dist/reporting/observer.mjs.map +1 -1
- package/dist/reporting/tracking.cjs +19 -4
- package/dist/reporting/tracking.cjs.map +1 -1
- package/dist/reporting/tracking.d.cts +13 -11
- package/dist/reporting/tracking.d.cts.map +1 -1
- package/dist/reporting/tracking.d.mts +13 -11
- package/dist/reporting/tracking.d.mts.map +1 -1
- package/dist/reporting/tracking.mjs +19 -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 +3 -2
- package/dist/transports/cloudflare-email.cjs.map +1 -1
- package/dist/transports/cloudflare-email.mjs +3 -2
- package/dist/transports/cloudflare-email.mjs.map +1 -1
- package/dist/transports/ses.cjs +52 -7
- 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 +52 -8
- 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/README.md
CHANGED
|
@@ -524,6 +524,15 @@ provider accepted just before the step's checkpoint failed can go out twice.
|
|
|
524
524
|
A host whose workflows send email but that has no `email` option (nor a
|
|
525
525
|
`messages` override) fails when it is created, naming those workflows.
|
|
526
526
|
|
|
527
|
+
An email with an unsubscribe link also carries `List-Unsubscribe` (RFC 2369), so
|
|
528
|
+
a mail client's own unsubscribe button works. With reporting on (a Postgres store and
|
|
529
|
+
`WORKFLOW_ORIGIN`), it names the copy's one-click endpoint `/t/u/<message>`, together with
|
|
530
|
+
`List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058). Gmail and Yahoo require
|
|
531
|
+
one-click unsubscribe for bulk senders. The provider's POST there unsubscribes at once, and the
|
|
532
|
+
same URL opened in a browser asks first. Without reporting, the header names the template's own
|
|
533
|
+
unsubscribe link, with no one-click claim. Only https URLs are named, so a local `http://`
|
|
534
|
+
origin sends no header.
|
|
535
|
+
|
|
527
536
|
An email step refuses to send when the profile has no `email`.
|
|
528
537
|
`GET /preview/<id>?props=<JSON>` renders a template, with the subject in the
|
|
529
538
|
`x-subject` header.
|
|
@@ -555,9 +564,23 @@ with its secret in the environment:
|
|
|
555
564
|
| `android` | `fcm: { androidChannelId? }` | `GOOGLE_APPLICATION_CREDENTIALS_BASE64` | `@shware/workflow/fcm` |
|
|
556
565
|
| `web` | `webPush: { subject, openedEvent? }` | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | `@shware/workflow/webpush` |
|
|
557
566
|
|
|
558
|
-
A secret is only read when something is sent on that platform.
|
|
559
|
-
|
|
560
|
-
|
|
567
|
+
A secret is only read when something is sent on that platform. Only a token
|
|
568
|
+
the provider reports as gone is removed (and waits on `reachable('push')` wake
|
|
569
|
+
on every device change); a configuration error keeps the device and fails the
|
|
570
|
+
step without retrying, and a transient answer is retried:
|
|
571
|
+
|
|
572
|
+
| Provider | Token removed, copy settled | Settled, device kept | Failed, no retry (`PermanentError`) | Retried |
|
|
573
|
+
| -------- | ----------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
574
|
+
| APNs | 410; `Unregistered`, `ExpiredToken` | `BadDeviceToken` (a token or the environment) | `DeviceTokenNotForTopic`, `TopicDisallowed`, `BadTopic`, `MissingTopic`, bad certificate or provider token, other 4xx | 403 `ExpiredProviderToken` (re-signed), 429, 5xx |
|
|
575
|
+
| FCM | `UNREGISTERED`; 404 `NOT_FOUND` | `INVALID_ARGUMENT` | `SENDER_ID_MISMATCH`, `THIRD_PARTY_AUTH_ERROR`, other 4xx | 401 (access token re-minted), `QUOTA_EXCEEDED`, `UNAVAILABLE`, 429, 5xx |
|
|
576
|
+
| Web Push | 404, 410 | — | payload over 3993 bytes (refused before sending), 400, 401, 403, 413, other 4xx | 429, 5xx |
|
|
577
|
+
|
|
578
|
+
Every APNs request carries `apns-id` and `apns-collapse-id`, and every web
|
|
579
|
+
push a `Topic`, derived from the copy's idempotency key, so a copy sent again
|
|
580
|
+
after a lost checkpoint collapses on the device. Provider credentials are kept
|
|
581
|
+
per host (`ApnsProviderToken`, `FcmAccessToken`, `VapidSigner`): the APNs
|
|
582
|
+
provider token, the FCM access token and the VAPID key are reused across runs,
|
|
583
|
+
while the senders themselves are built per run.
|
|
561
584
|
|
|
562
585
|
`apns.environment` maps a mode to `sandbox` or `production`; unlisted modes use
|
|
563
586
|
`production` in production and `sandbox` otherwise. Generate a VAPID pair once
|
|
@@ -717,20 +740,46 @@ backward-compatible changes, so a new required prop means a new id.
|
|
|
717
740
|
|
|
718
741
|
## Cloudflare setup
|
|
719
742
|
|
|
720
|
-
The default store needs
|
|
743
|
+
The default store needs two bindings, named as in `JourneyEnv`:
|
|
744
|
+
|
|
745
|
+
| Binding | Type | Holds |
|
|
746
|
+
| --------- | ----------- | ------------------------------------------------------ |
|
|
747
|
+
| `DB` | D1 database | Events, profiles, routing, compiled IR, entries, wakes |
|
|
748
|
+
| `JOURNEY` | Workflow | The `JourneyRunner` entrypoint |
|
|
721
749
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
| `DB` | D1 database | Events, profiles, routing, entries, wakes |
|
|
726
|
-
| `JOURNEY` | Workflow | The `JourneyRunner` entrypoint |
|
|
750
|
+
The compiled IR lives in D1 with the routes, never in a KV namespace: KV is eventually consistent
|
|
751
|
+
while the routes in D1 are not, so an instance started in another colo right after a deploy could
|
|
752
|
+
miss its IR.
|
|
727
753
|
|
|
728
754
|
A Worker on Postgres needs only `JOURNEY`. Add the `EMAIL` send_email binding
|
|
729
755
|
when it sends through Cloudflare Email (`cloudflareEmailTransport({ binding: env.EMAIL })`), and a `triggers.crons` entry with `scheduled`
|
|
730
756
|
exported to run the segment sweep.
|
|
731
757
|
|
|
732
|
-
Set `API_TOKEN` in production
|
|
733
|
-
|
|
758
|
+
Set `API_TOKEN` in production: a host whose `ENVIRONMENT` resolves to `production` refuses every
|
|
759
|
+
request but `GET /health` and `/t/...` with a 503 until it is set. Any other mode (`development`,
|
|
760
|
+
say) runs open, for local work only — `/deploy` rewrites the whole routing table.
|
|
761
|
+
|
|
762
|
+
Erasing a user (`POST /users/forget`) commits an erasure tombstone first,
|
|
763
|
+
which every write about the user is conditional on from then on. A runner
|
|
764
|
+
admits itself against that tombstone before its first effect — before the
|
|
765
|
+
observer hears of it, before any action or send — on every invocation and on
|
|
766
|
+
every replay after an eviction, never from a checkpoint: an instance whose
|
|
767
|
+
`create` lands after the tombstone, or that wakes after it, exits without
|
|
768
|
+
running. The erasure stops the admitted instances (`terminate`), and only once
|
|
769
|
+
each is known stopped deletes the data; `erased: true` means every admitted
|
|
770
|
+
runner was confirmed stopped, the entries never admitted can never run, and
|
|
771
|
+
the data is gone. An admitted instance that will not stop answers
|
|
772
|
+
`erased: false` and the scheduled sweep retries it. Every write about the user
|
|
773
|
+
runs in the user's locked transaction, the lock the tombstone itself takes: a
|
|
774
|
+
write either committed before the tombstone and is deleted by the erase, or
|
|
775
|
+
waited for the lock and is refused by the tombstone it then sees. There is no
|
|
776
|
+
window to come back for and no second pass; the tombstone stays, so a late
|
|
777
|
+
write cannot bring the user back.
|
|
778
|
+
|
|
779
|
+
One database per host. The routing tables are global (`triggers`, `segments`, the live
|
|
780
|
+
`deployment` row), so two Workers on one D1 database or one Postgres schema would swap each
|
|
781
|
+
other's routes on every deploy: give staging and production their own database or schema
|
|
782
|
+
(`database: { schema }`).
|
|
734
783
|
|
|
735
784
|
The D1 schema ships with the package. Apply it before the first deploy:
|
|
736
785
|
|
|
@@ -745,7 +794,7 @@ the single source for both.
|
|
|
745
794
|
There are no migrations, and re-applying the file is not one: every statement is
|
|
746
795
|
`CREATE TABLE IF NOT EXISTS`, so an existing database keeps its old tables and
|
|
747
796
|
the first insert needing a new column fails. Recreate it instead. The demo has
|
|
748
|
-
`pnpm db:reset`.
|
|
797
|
+
`pnpm db:reset`. The `events` key is `(user_id, id)`, and `workflow_ir` holds the compiled IR.
|
|
749
798
|
|
|
750
799
|
## AWS Lambda
|
|
751
800
|
|
|
@@ -795,11 +844,18 @@ re-entered as a second execution. Retries stop after 12 hours (`startRetryMs`,
|
|
|
795
844
|
keep it under the execution retention) and the start is left unresolved. A
|
|
796
845
|
failed entry that is entered again gets a new execution, never the closed one.
|
|
797
846
|
|
|
798
|
-
Erasing a user (`POST /users/forget`)
|
|
799
|
-
`stopDrainMs` (15 minutes by default; at least
|
|
800
|
-
erasing: a stop does not interrupt an invocation
|
|
801
|
-
to its next checkpoint. Until then the erasure
|
|
802
|
-
sweep finishes it
|
|
847
|
+
Erasing a user (`POST /users/forget`) commits the tombstone, stops their
|
|
848
|
+
admitted executions, then waits `stopDrainMs` (15 minutes by default; at least
|
|
849
|
+
the function's timeout) before erasing: a stop does not interrupt an invocation
|
|
850
|
+
already running, which goes on to its next checkpoint. Until then the erasure
|
|
851
|
+
answers `erased: false` and the sweep finishes it; the drain is one judgment
|
|
852
|
+
for every path — the sweep's reconcile keeps a stopped execution's entry
|
|
853
|
+
`running` until the drain has passed, and an execution the platform reports
|
|
854
|
+
stopped before the stop was recorded is dated at its first sighting, never
|
|
855
|
+
later. An execution whose runner never admitted itself needs no stop: its run
|
|
856
|
+
entry meets the tombstone and exits, and so does a wake of a stopped one. The
|
|
857
|
+
tombstone takes the user's lock, which every write about the user also takes,
|
|
858
|
+
so nothing of theirs lands after it and no second pass is needed.
|
|
803
859
|
|
|
804
860
|
Deploy one artifact per version: publish a numbered version and point the
|
|
805
861
|
`live` alias, which the Function URL is bound to, at it. Each version carries its
|
|
@@ -835,7 +891,10 @@ CloudFront (or another HTTPS proxy) in front of the Function URL.
|
|
|
835
891
|
## Postgres
|
|
836
892
|
|
|
837
893
|
A host on Postgres (through Hyperdrive, say) keeps Cloudflare Workflows for
|
|
838
|
-
execution and the data in its own database
|
|
894
|
+
execution and the data in its own database. Turn Hyperdrive's query caching off for this
|
|
895
|
+
database (`caching: { disabled: true }` on the Hyperdrive config): the engine reads wake
|
|
896
|
+
subscriptions, profiles and segment membership right after writing them, and a cached answer
|
|
897
|
+
up to 60 seconds old misses wakes and decides on stale data.
|
|
839
898
|
|
|
840
899
|
```ts
|
|
841
900
|
const journeys = journeyWorker<Env>({
|
|
@@ -850,7 +909,10 @@ says `database: { schema }`, so their short names never meet the app's. The
|
|
|
850
909
|
host owns the migrations. `npx workflow-ui generate --out <file>` (from
|
|
851
910
|
`@shware/workflow-ui`) writes the tables as one drizzle file on that schema;
|
|
852
911
|
re-export it from your drizzle schema and let drizzle-kit write the migration.
|
|
853
|
-
Run `--check` in CI to catch an SDK upgrade that changed them.
|
|
912
|
+
Run `--check` in CI to catch an SDK upgrade that changed them. The unique index
|
|
913
|
+
on `event` is on `(user_id, event_id)`: regenerate and migrate before deploying —
|
|
914
|
+
the deploy check verifies the unique indexes as well as the columns, and refuses
|
|
915
|
+
to run until they match. Without drizzle,
|
|
854
916
|
`renderPostgresDdl()` returns the same tables as plain DDL. Nothing creates or
|
|
855
917
|
alters them at runtime: `POST /bundle/deploy` checks the live database first
|
|
856
918
|
and answers 503 with every missing table or column.
|
|
@@ -901,13 +963,21 @@ On Postgres the engine also keeps:
|
|
|
901
963
|
|
|
902
964
|
`handleRequest` serves the ingest API:
|
|
903
965
|
|
|
904
|
-
- `GET /health` is the only unauthenticated one.
|
|
966
|
+
- `GET /health` is the only unauthenticated one. The bearer scheme is matched
|
|
967
|
+
case-insensitively and compared in constant time.
|
|
905
968
|
- `POST /events` takes `{ userId, event, payload?, ts?, id? }` and answers with
|
|
906
969
|
the id the occurrence was recorded under. Event names beginning with `$` are
|
|
907
|
-
reserved for internal use.
|
|
970
|
+
reserved for internal use. `ts` is epoch milliseconds: a non-negative integer
|
|
971
|
+
no later than five minutes ahead of the host's clock (400 otherwise).
|
|
908
972
|
- `POST /identify` takes `{ userId, props }` and merges the properties into the
|
|
909
|
-
profile.
|
|
910
|
-
one.
|
|
973
|
+
profile. Values are scalars (string, number, boolean); a property set to
|
|
974
|
+
`null` is removed, which is the only way to unset one. Nested objects are
|
|
975
|
+
refused with a 400.
|
|
976
|
+
- A user with an erasure tombstone (`POST /users/forget`, finished or not) is
|
|
977
|
+
refused on `/events`, `/identify` and `/devices` with a 410: nothing is
|
|
978
|
+
written about them between the stop of their journeys and the erase, and the
|
|
979
|
+
id is never reactivated after it.
|
|
980
|
+
- A body that is not JSON answers 400, never 500.
|
|
911
981
|
- `POST /devices` takes `{ userId, token, platform, ts? }`;
|
|
912
982
|
`POST /devices/unbind` and `POST /devices/remove` take `{ token }`.
|
|
913
983
|
- `POST /messages/opened` takes `{ userId, messageId }`.
|
|
@@ -922,8 +992,8 @@ anything is stored, woken or started.
|
|
|
922
992
|
|
|
923
993
|
### Give each event an `id`
|
|
924
994
|
|
|
925
|
-
`id` is the occurrence's identity
|
|
926
|
-
once. This matters because every count- and window-based condition reads the
|
|
995
|
+
`id` is the occurrence's identity **for that user**, and sending the same one
|
|
996
|
+
twice records it once. This matters because every count- and window-based condition reads the
|
|
927
997
|
event log, so a second copy of one event silently changes what a journey
|
|
928
998
|
decides.
|
|
929
999
|
|
|
@@ -938,7 +1008,8 @@ you can reproduce and the retry costs nothing.
|
|
|
938
1008
|
|
|
939
1009
|
Use something that identifies the occurrence, not the event name: a row id from
|
|
940
1010
|
your own system, an upstream webhook's delivery id, or a message id from your
|
|
941
|
-
queue.
|
|
1011
|
+
queue. The id is scoped to the user (the unique key is `(user_id, event_id)`),
|
|
1012
|
+
so two users may reuse one id without one of them losing their event. Workflows emitting events through `.sendEvent()` already do this, passing
|
|
942
1013
|
the node's identity within the running instance.
|
|
943
1014
|
|
|
944
1015
|
## Entry policy
|
|
@@ -961,10 +1032,10 @@ is a design question this package does not yet answer.
|
|
|
961
1032
|
| `@shware/workflow/postgres-js` | `postgresJsStore`, the Postgres store over postgres.js (peer `postgres`) |
|
|
962
1033
|
| `@shware/workflow/react-email` | `registryRenderer`, the email renderer over the registry (peers `react`, `react-dom`) |
|
|
963
1034
|
| `@shware/workflow/push` | `registryPushRenderer`, push content from the registry |
|
|
964
|
-
| `@shware/workflow/apns` | `ApnsPushSender`, token-based APNs over HTTP/2
|
|
1035
|
+
| `@shware/workflow/apns` | `ApnsPushSender` over an `ApnsProviderToken`, token-based APNs over HTTP/2 |
|
|
965
1036
|
| `@shware/workflow/apns-relay` | `startApnsRelay`, the Node HTTP/1.1 to HTTP/2 relay for `wrangler dev` |
|
|
966
|
-
| `@shware/workflow/fcm` | `FcmPushSender`, FCM HTTP v1 for Android
|
|
967
|
-
| `@shware/workflow/webpush` | `WebPushSender`
|
|
1037
|
+
| `@shware/workflow/fcm` | `FcmPushSender` over an `FcmAccessToken`, FCM HTTP v1 for Android |
|
|
1038
|
+
| `@shware/workflow/webpush` | `WebPushSender` over a `VapidSigner`, `generateVapidKeys`; standard Web Push with VAPID |
|
|
968
1039
|
| `@shware/workflow/webhooks` | `SlackWebhookSender` and `DiscordWebhookSender` |
|
|
969
1040
|
| `@shware/workflow/schema.sql` | The D1 schema file |
|
|
970
1041
|
|
package/dist/apns.cjs
CHANGED
|
@@ -1,22 +1,94 @@
|
|
|
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/apns.ts
|
|
4
5
|
const APNS_HOSTS = {
|
|
5
6
|
sandbox: "https://api.sandbox.push.apple.com",
|
|
6
7
|
production: "https://api.push.apple.com"
|
|
7
8
|
};
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
/**
|
|
10
|
+
* How an APNs answer is classified, after Apple's response table
|
|
11
|
+
* (https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns):
|
|
12
|
+
*
|
|
13
|
+
* - dead token: HTTP 410 `Unregistered` (the app was removed, or the token is inactive for the
|
|
14
|
+
* topic) and 400 `ExpiredToken`. The device will never deliver again, so the copy settles
|
|
15
|
+
* undelivered and the host forgets the token.
|
|
16
|
+
* - undelivered, device kept: 400 `BadDeviceToken`. Apple gives it for a malformed token but
|
|
17
|
+
* just as well for a token of the other environment (a sandbox build pushed through the
|
|
18
|
+
* production host), which would answer for every device at once; the copy settles with the
|
|
19
|
+
* reason, so the report shows it, and nothing is deleted on a guess.
|
|
20
|
+
* - configuration: the topic, certificate or provider key is wrong. Every device on this app
|
|
21
|
+
* would answer the same, so it is a `PermanentError` and the devices are kept — they are fine,
|
|
22
|
+
* the deployment is not.
|
|
23
|
+
* - transient: 429 (`TooManyRequests`, `TooManyProviderTokenUpdates`), 5xx (`InternalServerError`,
|
|
24
|
+
* `ServiceUnavailable`, `Shutdown`) and 403 `ExpiredProviderToken`, which a fresh token fixes.
|
|
25
|
+
* - everything else (400 `BadMessageId`, `BadPriority`, `PayloadEmpty`, 413 `PayloadTooLarge`,
|
|
26
|
+
* 404 `BadPath`, 405 `MethodNotAllowed`, …) is this request itself: a `PermanentError`.
|
|
27
|
+
*/
|
|
28
|
+
const DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set(["Unregistered", "ExpiredToken"]);
|
|
29
|
+
/** The app's topic, certificate or provider key is wrong: the request is refused for every device. */
|
|
30
|
+
const CONFIGURATION_REASONS = /* @__PURE__ */ new Set([
|
|
12
31
|
"DeviceTokenNotForTopic",
|
|
13
|
-
"
|
|
32
|
+
"TopicDisallowed",
|
|
33
|
+
"BadTopic",
|
|
34
|
+
"MissingTopic",
|
|
35
|
+
"BadCertificate",
|
|
36
|
+
"BadCertificateEnvironment",
|
|
37
|
+
"Forbidden",
|
|
38
|
+
"InvalidProviderToken",
|
|
39
|
+
"MissingProviderToken"
|
|
14
40
|
]);
|
|
41
|
+
/**
|
|
42
|
+
* The copy's identity on the wire: a canonical UUID derived from the idempotency key (SHA-256, the
|
|
43
|
+
* first 16 bytes laid out as an RFC 9562 v8 UUID). The same on every attempt, so a resend of the
|
|
44
|
+
* same copy carries the same `apns-id` and — as `apns-collapse-id`, which is limited to 64 bytes —
|
|
45
|
+
* collapses onto the first notification. The whole key is hashed: cut to 64 bytes it would lose
|
|
46
|
+
* the node id at its end, and two nodes of one instance would collapse into one notification.
|
|
47
|
+
*/
|
|
48
|
+
async function apnsIdFromKey(idempotencyKey) {
|
|
49
|
+
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(idempotencyKey))).slice(0, 16);
|
|
50
|
+
bytes[6] = bytes[6] & 15 | 128;
|
|
51
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
52
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
53
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
54
|
+
}
|
|
15
55
|
/** Apple: reuse a provider token for at least 20 minutes, never past 60. */
|
|
16
56
|
const TOKEN_TTL_MS = 3e6;
|
|
57
|
+
/**
|
|
58
|
+
* The provider JWT for one APNs auth key, signed once and reused for `TOKEN_TTL_MS`: Apple
|
|
59
|
+
* answers a sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`. Concurrent
|
|
60
|
+
* callers on a fresh or aged token share the one signing in flight, and a signing that failed is
|
|
61
|
+
* not kept, so the next call tries again. One instance serves every sender built on the key — a
|
|
62
|
+
* host keeps it across runs, and builds the senders per run.
|
|
63
|
+
*/
|
|
64
|
+
var ApnsProviderToken = class {
|
|
65
|
+
options;
|
|
66
|
+
signed;
|
|
67
|
+
signing;
|
|
68
|
+
constructor(options) {
|
|
69
|
+
this.options = options;
|
|
70
|
+
}
|
|
71
|
+
/** The current token, signed now if there is none or it has aged out. */
|
|
72
|
+
async token(now = Date.now()) {
|
|
73
|
+
if (this.signed !== void 0 && now - this.signed.issuedAt < TOKEN_TTL_MS) return this.signed.value;
|
|
74
|
+
this.signing ??= signApnsJwt(this.options, now).then((value) => {
|
|
75
|
+
this.signed = {
|
|
76
|
+
value,
|
|
77
|
+
issuedAt: now
|
|
78
|
+
};
|
|
79
|
+
return value;
|
|
80
|
+
}).finally(() => {
|
|
81
|
+
this.signing = void 0;
|
|
82
|
+
});
|
|
83
|
+
return this.signing;
|
|
84
|
+
}
|
|
85
|
+
/** Forget the token: APNs refused it (`ExpiredProviderToken`), so the next call signs a new one. */
|
|
86
|
+
invalidate() {
|
|
87
|
+
this.signed = void 0;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
17
90
|
var ApnsPushSender = class {
|
|
18
91
|
options;
|
|
19
|
-
token;
|
|
20
92
|
constructor(options) {
|
|
21
93
|
this.options = options;
|
|
22
94
|
}
|
|
@@ -27,13 +99,17 @@ var ApnsPushSender = class {
|
|
|
27
99
|
const mutable = image !== void 0 || message.tracking?.data?.delivered_url !== void 0;
|
|
28
100
|
const upstream = APNS_HOSTS[this.options.environment];
|
|
29
101
|
const origin = this.options.origin ?? upstream;
|
|
30
|
-
const
|
|
102
|
+
const doFetch = this.options.fetch ?? fetch;
|
|
103
|
+
const copyId = await apnsIdFromKey(message.idempotencyKey);
|
|
104
|
+
const response = await doFetch(`${origin}/3/device/${message.recipient}`, {
|
|
31
105
|
method: "POST",
|
|
32
106
|
headers: {
|
|
33
|
-
authorization: `bearer ${await this.
|
|
107
|
+
authorization: `bearer ${await this.options.credentials.token()}`,
|
|
34
108
|
"apns-topic": this.options.topic,
|
|
35
109
|
"apns-push-type": "alert",
|
|
36
110
|
"apns-priority": "10",
|
|
111
|
+
"apns-id": copyId,
|
|
112
|
+
"apns-collapse-id": copyId,
|
|
37
113
|
"content-type": "application/json",
|
|
38
114
|
...this.options.origin === void 0 ? {} : { "apns-relay-upstream": upstream }
|
|
39
115
|
},
|
|
@@ -55,26 +131,29 @@ var ApnsPushSender = class {
|
|
|
55
131
|
});
|
|
56
132
|
if (response.ok) return;
|
|
57
133
|
const text = await response.text();
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
134
|
+
const { status } = response;
|
|
135
|
+
const reason = apnsReason(text) || (status === 410 ? "Unregistered" : "");
|
|
136
|
+
if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {
|
|
137
|
+
console.warn(`[apns] token gone for user ${message.userId}: ${reason}`);
|
|
62
138
|
return {
|
|
63
139
|
delivered: false,
|
|
64
|
-
reason
|
|
140
|
+
reason
|
|
65
141
|
};
|
|
66
142
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
143
|
+
if (reason === "BadDeviceToken") {
|
|
144
|
+
console.warn(`[apns] refused for user ${message.userId}: ${status} ${reason}`);
|
|
145
|
+
return {
|
|
146
|
+
delivered: false,
|
|
147
|
+
reason
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (reason === "ExpiredProviderToken") {
|
|
151
|
+
this.options.credentials.invalidate();
|
|
152
|
+
throw new Error(`APNs send failed: ${status} ${reason} (provider token renewed for the retry)`);
|
|
153
|
+
}
|
|
154
|
+
if (CONFIGURATION_REASONS.has(reason)) throw new require_engine_retry.PermanentError(`APNs refused the request for topic '${this.options.topic}' (${this.options.environment}): ${status} ${reason}`);
|
|
155
|
+
if (require_engine_retry.isRetryableStatus(status)) throw new Error(`APNs send failed: ${status} ${reason || text}`);
|
|
156
|
+
throw new require_engine_retry.PermanentError(`APNs refused the request: ${status} ${reason || text}`);
|
|
78
157
|
}
|
|
79
158
|
};
|
|
80
159
|
/** APNs error bodies are `{ "reason": "…" }`. */
|
|
@@ -107,13 +186,21 @@ async function signApnsJwt(key, nowMs = Date.now()) {
|
|
|
107
186
|
}
|
|
108
187
|
/** The `.p8` as it is usually shipped in a secret: base64 of the file. */
|
|
109
188
|
function apnsKeyFromBase64(value) {
|
|
110
|
-
|
|
111
|
-
|
|
189
|
+
let pem;
|
|
190
|
+
try {
|
|
191
|
+
pem = atob(value);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
throw new require_engine_retry.PermanentError("APNs key is not base64 (expected the .p8 file, base64-encoded)", { cause: error });
|
|
194
|
+
}
|
|
195
|
+
if (!pem.includes("-----BEGIN PRIVATE KEY-----")) throw new require_engine_retry.PermanentError("APNs key is not a PKCS #8 PEM (.p8) file");
|
|
112
196
|
return pem;
|
|
113
197
|
}
|
|
114
198
|
//#endregion
|
|
115
199
|
exports.APNS_HOSTS = APNS_HOSTS;
|
|
200
|
+
exports.ApnsProviderToken = ApnsProviderToken;
|
|
116
201
|
exports.ApnsPushSender = ApnsPushSender;
|
|
202
|
+
exports.DEAD_TOKEN_REASONS = DEAD_TOKEN_REASONS;
|
|
203
|
+
exports.apnsIdFromKey = apnsIdFromKey;
|
|
117
204
|
exports.apnsKeyFromBase64 = apnsKeyFromBase64;
|
|
118
205
|
exports.apnsReason = apnsReason;
|
|
119
206
|
exports.signApnsJwt = signApnsJwt;
|
package/dist/apns.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"apns.cjs","names":["base64url","pemToDer"],"sources":["../src/apns.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { base64url, pemToDer } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/apns` — iOS push delivery straight to the Apple Push\n * Notification service, token-based (an APNs auth key, the `.p8` file from the\n * developer portal). No Firebase in between: the app registers the APNs device\n * token in its own device table, and the host's DeviceDirectory hands it to the\n * engine, which sends one message per device.\n * No dependencies: the provider JWT is ES256-signed with WebCrypto.\n *\n * APNs speaks HTTP/2 only. A deployed Worker's `fetch` negotiates it, but\n * workerd under `wrangler dev` does not (the connection is dropped), so a local\n * host points `origin` at the Node relay in `@shware/workflow/apns-relay`.\n */\n\nexport interface ApnsPushOptions {\n /** The key's 10-character id, shown next to it in the developer portal. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /** The `.p8` file's contents: PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8, P-256). */\n privateKey: string;\n /** The app's bundle id — APNs's `apns-topic`. */\n topic: string;\n /**\n * Which APNs environment the device tokens come from: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. A token from one is\n * `BadDeviceToken` on the other.\n */\n environment: 'sandbox' | 'production';\n render: PushRenderer;\n /**\n * POST here instead of Apple's host — the dev relay, which forwards over\n * HTTP/2. The real host still travels along (`apns-relay-upstream`), so the\n * relay needs no configuration of its own.\n */\n origin?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n /** Called for a token APNs reports as gone (`Unregistered`, `BadDeviceToken`); `channelsFromConfig` passes it on to the DeviceDirectory's `disable`. */\n onUnregistered?: (message: OutboundMessage) => Promise<void> | void;\n}\n\nexport const APNS_HOSTS = {\n sandbox: 'https://api.sandbox.push.apple.com',\n production: 'https://api.push.apple.com',\n} as const;\n\n/** Reasons APNs gives for a token that will never deliver again — not worth a retry. */\nconst DEAD_TOKEN_REASONS = new Set([\n 'Unregistered',\n 'BadDeviceToken',\n 'DeviceTokenNotForTopic',\n 'ExpiredToken',\n]);\n\n/** Apple: reuse a provider token for at least 20 minutes, never past 60. */\nconst TOKEN_TTL_MS = 50 * 60_000;\n\nexport class ApnsPushSender implements MessageSender {\n private token: { value: string; issuedAt: number } | undefined;\n\n constructor(private readonly options: ApnsPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`ApnsPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`ApnsPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n // A notification service extension runs only for a mutable push: it attaches the image and\n // reports the device delivery (`delivered_url`)\n const mutable = image !== undefined || message.tracking?.data?.delivered_url !== undefined;\n const upstream = APNS_HOSTS[this.options.environment];\n const origin = this.options.origin ?? upstream;\n const doFetch = this.options.fetch ?? fetch;\n\n const response = await doFetch(`${origin}/3/device/${message.recipient}`, {\n method: 'POST',\n headers: {\n authorization: `bearer ${await this.providerToken()}`,\n 'apns-topic': this.options.topic,\n 'apns-push-type': 'alert',\n 'apns-priority': '10',\n 'content-type': 'application/json',\n ...(this.options.origin === undefined ? {} : { 'apns-relay-upstream': upstream }),\n },\n body: JSON.stringify({\n aps: {\n alert: { title, body },\n sound: 'default',\n ...(mutable ? { 'mutable-content': 1 } : {}),\n },\n // The template key travels so the app can route a tap; the idempotency\n // key lets the app de-duplicate a redelivered push.\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(image === undefined ? {} : { image }),\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n }),\n });\n if (response.ok) return;\n\n const text = await response.text();\n const reason = apnsReason(text);\n if (response.status === 410 || DEAD_TOKEN_REASONS.has(reason)) {\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(`[apns] token gone for user ${message.userId}: ${reason || response.status}`);\n await this.options.onUnregistered?.(message);\n return { delivered: false, reason: reason || `HTTP ${response.status}` };\n }\n throw new Error(`APNs send failed: ${response.status} ${text}`);\n }\n\n private async providerToken(): Promise<string> {\n const now = Date.now();\n if (this.token !== undefined && now - this.token.issuedAt < TOKEN_TTL_MS) {\n return this.token.value;\n }\n const value = await signApnsJwt(this.options, now);\n this.token = { value, issuedAt: now };\n return value;\n }\n}\n\n/** APNs error bodies are `{ \"reason\": \"…\" }`. */\nexport function apnsReason(body: string): string {\n try {\n const parsed = JSON.parse(body) as { reason?: string };\n return parsed.reason ?? '';\n } catch {\n return '';\n }\n}\n\n/** ES256-sign the APNs provider token: header `kid`, claims `iss` (team) and `iat`. */\nexport async function signApnsJwt(\n key: Pick<ApnsPushOptions, 'keyId' | 'teamId' | 'privateKey'>,\n nowMs = Date.now()\n): Promise<string> {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: key.keyId }));\n const claims = base64url(JSON.stringify({ iss: key.teamId, iat: Math.floor(nowMs / 1000) }));\n const cryptoKey = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(key.privateKey),\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n );\n // WebCrypto's ECDSA signature is the raw r‖s pair, which is exactly JWS's ES256 encoding.\n const signature = await crypto.subtle.sign(\n { name: 'ECDSA', hash: 'SHA-256' },\n cryptoKey,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** The `.p8` as it is usually shipped in a secret: base64 of the file. */\nexport function apnsKeyFromBase64(value: string): string {\n const pem = atob(value);\n if (!pem.includes('-----BEGIN PRIVATE KEY-----')) {\n throw new Error('APNs key is not a PKCS #8 PEM (.p8) file');\n }\n return pem;\n}\n"],"mappings":";;;AA8CA,MAAa,aAAa;CACxB,SAAS;CACT,YAAY;AACd;;AAGA,MAAM,qCAAqB,IAAI,IAAI;CACjC;CACA;CACA;CACA;AACF,CAAC;;AAGD,MAAM,eAAe;AAErB,IAAa,iBAAb,MAAqD;CAGtB;CAF7B;CAEA,YAAY,SAA2C;EAA1B,KAAA,UAAA;CAA2B;CAExD,MAAM,KAAK,SAAsD;EAC/D,IAAI,QAAQ,YAAY,QACtB,MAAM,IAAI,MAAM,wCAAwC,QAAQ,QAAQ,EAAE;EAE5E,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,2CAA2C,QAAQ,OAAO,EAAE;EAE9E,MAAM,EAAE,OAAO,MAAM,OAAO,QAAQ,MAAM,KAAK,QAAQ,OAAO,QAAQ,UAAU,QAAQ,KAAK;EAG7F,MAAM,UAAU,UAAU,KAAA,KAAa,QAAQ,UAAU,MAAM,kBAAkB,KAAA;EACjF,MAAM,WAAW,WAAW,KAAK,QAAQ;EACzC,MAAM,SAAS,KAAK,QAAQ,UAAU;EAGtC,MAAM,WAAW,OAFD,KAAK,QAAQ,SAAS,MAAA,CAEP,GAAG,OAAO,YAAY,QAAQ,aAAa;GACxE,QAAQ;GACR,SAAS;IACP,eAAe,UAAU,MAAM,KAAK,cAAc;IAClD,cAAc,KAAK,QAAQ;IAC3B,kBAAkB;IAClB,iBAAiB;IACjB,gBAAgB;IAChB,GAAI,KAAK,QAAQ,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,uBAAuB,SAAS;GACjF;GACA,MAAM,KAAK,UAAU;IACnB,KAAK;KACH,OAAO;MAAE;MAAO;KAAK;KACrB,OAAO;KACP,GAAI,UAAU,EAAE,mBAAmB,EAAE,IAAI,CAAC;IAC5C;IAGA,UAAU,QAAQ;IAClB,gBAAgB,QAAQ;IACxB,GAAI,UAAU,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM;IACvC,GAAI,QAAQ,KAAA,IAAY,CAAC,IAAI,EAAE,IAAI;IACnC,GAAG,QAAQ,UAAU;GACvB,CAAC;EACH,CAAC;EACD,IAAI,SAAS,IAAI;EAEjB,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,MAAM,SAAS,WAAW,IAAI;EAC9B,IAAI,SAAS,WAAW,OAAO,mBAAmB,IAAI,MAAM,GAAG;GAG7D,QAAQ,KAAK,8BAA8B,QAAQ,OAAO,IAAI,UAAU,SAAS,QAAQ;GACzF,MAAM,KAAK,QAAQ,iBAAiB,OAAO;GAC3C,OAAO;IAAE,WAAW;IAAO,QAAQ,UAAU,QAAQ,SAAS;GAAS;EACzE;EACA,MAAM,IAAI,MAAM,qBAAqB,SAAS,OAAO,GAAG,MAAM;CAChE;CAEA,MAAc,gBAAiC;EAC7C,MAAM,MAAM,KAAK,IAAI;EACrB,IAAI,KAAK,UAAU,KAAA,KAAa,MAAM,KAAK,MAAM,WAAW,cAC1D,OAAO,KAAK,MAAM;EAEpB,MAAM,QAAQ,MAAM,YAAY,KAAK,SAAS,GAAG;EACjD,KAAK,QAAQ;GAAE;GAAO,UAAU;EAAI;EACpC,OAAO;CACT;AACF;;AAGA,SAAgB,WAAW,MAAsB;CAC/C,IAAI;EAEF,OADe,KAAK,MAAM,IACd,CAAC,CAAC,UAAU;CAC1B,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,eAAsB,YACpB,KACA,QAAQ,KAAK,IAAI,GACA;CACjB,MAAM,SAASA,YAAAA,UAAU,KAAK,UAAU;EAAE,KAAK;EAAS,KAAK,IAAI;CAAM,CAAC,CAAC;CACzE,MAAM,SAASA,YAAAA,UAAU,KAAK,UAAU;EAAE,KAAK,IAAI;EAAQ,KAAK,KAAK,MAAM,QAAQ,GAAI;CAAE,CAAC,CAAC;CAC3F,MAAM,YAAY,MAAM,OAAO,OAAO,UACpC,SACAC,YAAAA,SAAS,IAAI,UAAU,GACvB;EAAE,MAAM;EAAS,YAAY;CAAQ,GACrC,OACA,CAAC,MAAM,CACT;CAEA,MAAM,YAAY,MAAM,OAAO,OAAO,KACpC;EAAE,MAAM;EAAS,MAAM;CAAU,GACjC,WACA,IAAI,YAAY,CAAC,CAAC,OAAO,GAAG,OAAO,GAAG,QAAQ,CAChD;CACA,OAAO,GAAG,OAAO,GAAG,OAAO,GAAGD,YAAAA,UAAU,IAAI,WAAW,SAAS,CAAC;AACnE;;AAGA,SAAgB,kBAAkB,OAAuB;CACvD,MAAM,MAAM,KAAK,KAAK;CACtB,IAAI,CAAC,IAAI,SAAS,6BAA6B,GAC7C,MAAM,IAAI,MAAM,0CAA0C;CAE5D,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"apns.cjs","names":["PermanentError","isRetryableStatus","base64url","pemToDer"],"sources":["../src/apns.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/apns` — iOS push delivery straight to the Apple Push\n * Notification service, token-based (an APNs auth key, the `.p8` file from the\n * developer portal). No Firebase in between: the app registers the APNs device\n * token in its own device table, and the host's DeviceDirectory hands it to the\n * engine, which sends one message per device.\n * No dependencies: the provider JWT is ES256-signed with WebCrypto.\n *\n * APNs speaks HTTP/2 only. A deployed Worker's `fetch` negotiates it, but\n * workerd under `wrangler dev` does not (the connection is dropped), so a local\n * host points `origin` at the Node relay in `@shware/workflow/apns-relay`.\n */\n\n/** The APNs auth key — the `.p8` file from the developer portal, and the ids that name it. */\nexport interface ApnsProviderTokenOptions {\n /** The key's 10-character id, shown next to it in the developer portal. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /** The `.p8` file's contents: PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8, P-256). */\n privateKey: string;\n}\n\nexport interface ApnsPushOptions {\n /** The provider token the requests are signed with — shared by every sender on the same key. */\n credentials: ApnsProviderToken;\n /** The app's bundle id — APNs's `apns-topic`. */\n topic: string;\n /**\n * Which APNs environment the device tokens come from: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. A token from one is\n * `BadDeviceToken` on the other.\n */\n environment: 'sandbox' | 'production';\n render: PushRenderer;\n /**\n * POST here instead of Apple's host — the dev relay, which forwards over\n * HTTP/2. The real host still travels along (`apns-relay-upstream`), so the\n * relay needs no configuration of its own.\n */\n origin?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport const APNS_HOSTS = {\n sandbox: 'https://api.sandbox.push.apple.com',\n production: 'https://api.push.apple.com',\n} as const;\n\n/**\n * How an APNs answer is classified, after Apple's response table\n * (https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns):\n *\n * - dead token: HTTP 410 `Unregistered` (the app was removed, or the token is inactive for the\n * topic) and 400 `ExpiredToken`. The device will never deliver again, so the copy settles\n * undelivered and the host forgets the token.\n * - undelivered, device kept: 400 `BadDeviceToken`. Apple gives it for a malformed token but\n * just as well for a token of the other environment (a sandbox build pushed through the\n * production host), which would answer for every device at once; the copy settles with the\n * reason, so the report shows it, and nothing is deleted on a guess.\n * - configuration: the topic, certificate or provider key is wrong. Every device on this app\n * would answer the same, so it is a `PermanentError` and the devices are kept — they are fine,\n * the deployment is not.\n * - transient: 429 (`TooManyRequests`, `TooManyProviderTokenUpdates`), 5xx (`InternalServerError`,\n * `ServiceUnavailable`, `Shutdown`) and 403 `ExpiredProviderToken`, which a fresh token fixes.\n * - everything else (400 `BadMessageId`, `BadPriority`, `PayloadEmpty`, 413 `PayloadTooLarge`,\n * 404 `BadPath`, 405 `MethodNotAllowed`, …) is this request itself: a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set(['Unregistered', 'ExpiredToken']);\n\n/** The app's topic, certificate or provider key is wrong: the request is refused for every device. */\nconst CONFIGURATION_REASONS: ReadonlySet<string> = new Set([\n 'DeviceTokenNotForTopic',\n 'TopicDisallowed',\n 'BadTopic',\n 'MissingTopic',\n 'BadCertificate',\n 'BadCertificateEnvironment',\n 'Forbidden',\n 'InvalidProviderToken',\n 'MissingProviderToken',\n]);\n\n/**\n * The copy's identity on the wire: a canonical UUID derived from the idempotency key (SHA-256, the\n * first 16 bytes laid out as an RFC 9562 v8 UUID). The same on every attempt, so a resend of the\n * same copy carries the same `apns-id` and — as `apns-collapse-id`, which is limited to 64 bytes —\n * collapses onto the first notification. The whole key is hashed: cut to 64 bytes it would lose\n * the node id at its end, and two nodes of one instance would collapse into one notification.\n */\nexport async function apnsIdFromKey(idempotencyKey: string): Promise<string> {\n const digest = new Uint8Array(\n await crypto.subtle.digest('SHA-256', new TextEncoder().encode(idempotencyKey))\n );\n const bytes = digest.slice(0, 16);\n bytes[6] = (bytes[6] & 0x0f) | 0x80; // version 8: custom, deterministic\n bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant\n const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\n/** Apple: reuse a provider token for at least 20 minutes, never past 60. */\nconst TOKEN_TTL_MS = 50 * 60_000;\n\n/**\n * The provider JWT for one APNs auth key, signed once and reused for `TOKEN_TTL_MS`: Apple\n * answers a sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`. Concurrent\n * callers on a fresh or aged token share the one signing in flight, and a signing that failed is\n * not kept, so the next call tries again. One instance serves every sender built on the key — a\n * host keeps it across runs, and builds the senders per run.\n */\nexport class ApnsProviderToken {\n private signed: { value: string; issuedAt: number } | undefined;\n private signing: Promise<string> | undefined;\n\n constructor(private readonly options: ApnsProviderTokenOptions) {}\n\n /** The current token, signed now if there is none or it has aged out. */\n async token(now = Date.now()): Promise<string> {\n if (this.signed !== undefined && now - this.signed.issuedAt < TOKEN_TTL_MS) {\n return this.signed.value;\n }\n this.signing ??= signApnsJwt(this.options, now)\n .then((value) => {\n this.signed = { value, issuedAt: now };\n return value;\n })\n .finally(() => {\n this.signing = undefined;\n });\n return this.signing;\n }\n\n /** Forget the token: APNs refused it (`ExpiredProviderToken`), so the next call signs a new one. */\n invalidate(): void {\n this.signed = undefined;\n }\n}\n\nexport class ApnsPushSender implements MessageSender {\n constructor(private readonly options: ApnsPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`ApnsPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`ApnsPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n // A notification service extension runs only for a mutable push: it attaches the image and\n // reports the device delivery (`delivered_url`)\n const mutable = image !== undefined || message.tracking?.data?.delivered_url !== undefined;\n const upstream = APNS_HOSTS[this.options.environment];\n const origin = this.options.origin ?? upstream;\n const doFetch = this.options.fetch ?? fetch;\n\n const copyId = await apnsIdFromKey(message.idempotencyKey);\n const response = await doFetch(`${origin}/3/device/${message.recipient}`, {\n method: 'POST',\n headers: {\n authorization: `bearer ${await this.options.credentials.token()}`,\n 'apns-topic': this.options.topic,\n 'apns-push-type': 'alert',\n 'apns-priority': '10',\n // A resend of the same copy (an at-least-once retry after APNs accepted the first attempt)\n // carries the same id and collapses onto the first notification instead of showing twice\n 'apns-id': copyId,\n 'apns-collapse-id': copyId,\n 'content-type': 'application/json',\n ...(this.options.origin === undefined ? {} : { 'apns-relay-upstream': upstream }),\n },\n body: JSON.stringify({\n aps: {\n alert: { title, body },\n sound: 'default',\n ...(mutable ? { 'mutable-content': 1 } : {}),\n },\n // The template key travels so the app can route a tap; the idempotency\n // key lets the app de-duplicate a redelivered push.\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(image === undefined ? {} : { image }),\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n }),\n });\n if (response.ok) return;\n\n const text = await response.text();\n const { status } = response;\n // A 410 without a body is still \"unregistered\": settle it under that reason\n const reason = apnsReason(text) || (status === 410 ? 'Unregistered' : '');\n if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {\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(`[apns] token gone for user ${message.userId}: ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'BadDeviceToken') {\n // The token or the environment: settled so the report says which device, nothing deleted\n console.warn(`[apns] refused for user ${message.userId}: ${status} ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'ExpiredProviderToken') {\n // The token aged out (clock skew, a long-lived isolate): sign a new one on the retry\n this.options.credentials.invalidate();\n throw new Error(\n `APNs send failed: ${status} ${reason} (provider token renewed for the retry)`\n );\n }\n if (CONFIGURATION_REASONS.has(reason)) {\n throw new PermanentError(\n `APNs refused the request for topic '${this.options.topic}' (${this.options.environment}): ${status} ${reason}`\n );\n }\n if (isRetryableStatus(status)) {\n throw new Error(`APNs send failed: ${status} ${reason || text}`);\n }\n throw new PermanentError(`APNs refused the request: ${status} ${reason || text}`);\n }\n}\n\n/** APNs error bodies are `{ \"reason\": \"…\" }`. */\nexport function apnsReason(body: string): string {\n try {\n const parsed = JSON.parse(body) as { reason?: string };\n return parsed.reason ?? '';\n } catch {\n return '';\n }\n}\n\n/** ES256-sign the APNs provider token: header `kid`, claims `iss` (team) and `iat`. */\nexport async function signApnsJwt(\n key: ApnsProviderTokenOptions,\n nowMs = Date.now()\n): Promise<string> {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: key.keyId }));\n const claims = base64url(JSON.stringify({ iss: key.teamId, iat: Math.floor(nowMs / 1000) }));\n const cryptoKey = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(key.privateKey),\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n );\n // WebCrypto's ECDSA signature is the raw r‖s pair, which is exactly JWS's ES256 encoding.\n const signature = await crypto.subtle.sign(\n { name: 'ECDSA', hash: 'SHA-256' },\n cryptoKey,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** The `.p8` as it is usually shipped in a secret: base64 of the file. */\nexport function apnsKeyFromBase64(value: string): string {\n let pem: string;\n try {\n pem = atob(value);\n } catch (error) {\n // A secret that is not base64 is the same on every attempt: not worth a retry\n throw new PermanentError('APNs key is not base64 (expected the .p8 file, base64-encoded)', {\n cause: error,\n });\n }\n if (!pem.includes('-----BEGIN PRIVATE KEY-----')) {\n throw new PermanentError('APNs key is not a PKCS #8 PEM (.p8) file');\n }\n return pem;\n}\n"],"mappings":"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"}
|
package/dist/apns.d.cts
CHANGED
|
@@ -14,13 +14,18 @@ import { PushRenderer } from "./push.cjs";
|
|
|
14
14
|
* workerd under `wrangler dev` does not (the connection is dropped), so a local
|
|
15
15
|
* host points `origin` at the Node relay in `@shware/workflow/apns-relay`.
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
/** The APNs auth key — the `.p8` file from the developer portal, and the ids that name it. */
|
|
18
|
+
export interface ApnsProviderTokenOptions {
|
|
18
19
|
/** The key's 10-character id, shown next to it in the developer portal. */
|
|
19
20
|
keyId: string;
|
|
20
21
|
/** The Apple Developer team id. */
|
|
21
22
|
teamId: string;
|
|
22
23
|
/** The `.p8` file's contents: PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8, P-256). */
|
|
23
24
|
privateKey: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ApnsPushOptions {
|
|
27
|
+
/** The provider token the requests are signed with — shared by every sender on the same key. */
|
|
28
|
+
credentials: ApnsProviderToken;
|
|
24
29
|
/** The app's bundle id — APNs's `apns-topic`. */
|
|
25
30
|
topic: string;
|
|
26
31
|
/**
|
|
@@ -39,24 +44,65 @@ export interface ApnsPushOptions {
|
|
|
39
44
|
origin?: string;
|
|
40
45
|
/** Fetch implementation (defaults to the global one). */
|
|
41
46
|
fetch?: typeof fetch;
|
|
42
|
-
/** Called for a token APNs reports as gone (`Unregistered`, `BadDeviceToken`); `channelsFromConfig` passes it on to the DeviceDirectory's `disable`. */
|
|
43
|
-
onUnregistered?: (message: OutboundMessage) => Promise<void> | void;
|
|
44
47
|
}
|
|
45
48
|
export declare const APNS_HOSTS: {
|
|
46
49
|
readonly sandbox: 'https://api.sandbox.push.apple.com';
|
|
47
50
|
readonly production: 'https://api.push.apple.com';
|
|
48
51
|
};
|
|
52
|
+
/**
|
|
53
|
+
* How an APNs answer is classified, after Apple's response table
|
|
54
|
+
* (https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns):
|
|
55
|
+
*
|
|
56
|
+
* - dead token: HTTP 410 `Unregistered` (the app was removed, or the token is inactive for the
|
|
57
|
+
* topic) and 400 `ExpiredToken`. The device will never deliver again, so the copy settles
|
|
58
|
+
* undelivered and the host forgets the token.
|
|
59
|
+
* - undelivered, device kept: 400 `BadDeviceToken`. Apple gives it for a malformed token but
|
|
60
|
+
* just as well for a token of the other environment (a sandbox build pushed through the
|
|
61
|
+
* production host), which would answer for every device at once; the copy settles with the
|
|
62
|
+
* reason, so the report shows it, and nothing is deleted on a guess.
|
|
63
|
+
* - configuration: the topic, certificate or provider key is wrong. Every device on this app
|
|
64
|
+
* would answer the same, so it is a `PermanentError` and the devices are kept — they are fine,
|
|
65
|
+
* the deployment is not.
|
|
66
|
+
* - transient: 429 (`TooManyRequests`, `TooManyProviderTokenUpdates`), 5xx (`InternalServerError`,
|
|
67
|
+
* `ServiceUnavailable`, `Shutdown`) and 403 `ExpiredProviderToken`, which a fresh token fixes.
|
|
68
|
+
* - everything else (400 `BadMessageId`, `BadPriority`, `PayloadEmpty`, 413 `PayloadTooLarge`,
|
|
69
|
+
* 404 `BadPath`, 405 `MethodNotAllowed`, …) is this request itself: a `PermanentError`.
|
|
70
|
+
*/
|
|
71
|
+
export declare const DEAD_TOKEN_REASONS: ReadonlySet<string>;
|
|
72
|
+
/**
|
|
73
|
+
* The copy's identity on the wire: a canonical UUID derived from the idempotency key (SHA-256, the
|
|
74
|
+
* first 16 bytes laid out as an RFC 9562 v8 UUID). The same on every attempt, so a resend of the
|
|
75
|
+
* same copy carries the same `apns-id` and — as `apns-collapse-id`, which is limited to 64 bytes —
|
|
76
|
+
* collapses onto the first notification. The whole key is hashed: cut to 64 bytes it would lose
|
|
77
|
+
* the node id at its end, and two nodes of one instance would collapse into one notification.
|
|
78
|
+
*/
|
|
79
|
+
export declare function apnsIdFromKey(idempotencyKey: string): Promise<string>;
|
|
80
|
+
/**
|
|
81
|
+
* The provider JWT for one APNs auth key, signed once and reused for `TOKEN_TTL_MS`: Apple
|
|
82
|
+
* answers a sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`. Concurrent
|
|
83
|
+
* callers on a fresh or aged token share the one signing in flight, and a signing that failed is
|
|
84
|
+
* not kept, so the next call tries again. One instance serves every sender built on the key — a
|
|
85
|
+
* host keeps it across runs, and builds the senders per run.
|
|
86
|
+
*/
|
|
87
|
+
export declare class ApnsProviderToken {
|
|
88
|
+
private readonly options;
|
|
89
|
+
private signed;
|
|
90
|
+
private signing;
|
|
91
|
+
constructor(options: ApnsProviderTokenOptions);
|
|
92
|
+
/** The current token, signed now if there is none or it has aged out. */
|
|
93
|
+
token(now?: number): Promise<string>;
|
|
94
|
+
/** Forget the token: APNs refused it (`ExpiredProviderToken`), so the next call signs a new one. */
|
|
95
|
+
invalidate(): void;
|
|
96
|
+
}
|
|
49
97
|
export declare class ApnsPushSender implements MessageSender {
|
|
50
98
|
private readonly options;
|
|
51
|
-
private token;
|
|
52
99
|
constructor(options: ApnsPushOptions);
|
|
53
100
|
send(message: OutboundMessage): Promise<void | SendResult>;
|
|
54
|
-
private providerToken;
|
|
55
101
|
}
|
|
56
102
|
/** APNs error bodies are `{ "reason": "…" }`. */
|
|
57
103
|
export declare function apnsReason(body: string): string;
|
|
58
104
|
/** ES256-sign the APNs provider token: header `kid`, claims `iss` (team) and `iat`. */
|
|
59
|
-
export declare function signApnsJwt(key:
|
|
105
|
+
export declare function signApnsJwt(key: ApnsProviderTokenOptions, nowMs?: number): Promise<string>;
|
|
60
106
|
/** The `.p8` as it is usually shipped in a secret: base64 of the file. */
|
|
61
107
|
export declare function apnsKeyFromBase64(value: string): string;
|
|
62
108
|
//#endregion
|
package/dist/apns.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"apns.d.cts","names":[],"sources":["../src/apns.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"apns.d.cts","names":[],"sources":["../src/apns.ts"],"mappings":";;;;;;;;;;;;;;;;;iBAmBiB;;EAEf;;EAEA;;EAEA;;iBAGe;;EAEf,aAAa;;EAEb;;;;;;;EAOA;EACA,QAAQ;;;;;;EAMR;;EAEA,eAAe;;qBAGJ;WACX;WACA;;;;;;;;;;;;;;;;;;;;;qBAsBW,oBAAoB;;;;;;;;wBAsBX,cAAc,yBAAyB;;;;;;;;qBAqBhD;mBAIkB;UAHrB;UACA;EAER,YAA6B,SAAS;;EAGhC,MAAM,eAAmB;;EAgB/B;;qBAKW,0BAA0B;mBACR;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;wBAkFvC,WAAW;;wBAUL,YACpB,KAAK,0BACL,iBACC;;wBAoBa,kBAAkB"}
|