@shware/workflow 4.1.0 → 5.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 +16 -38
- package/dist/apns.cjs +3 -2
- package/dist/apns.cjs.map +1 -1
- package/dist/apns.d.cts.map +1 -1
- package/dist/apns.d.mts.map +1 -1
- package/dist/apns.mjs +3 -2
- package/dist/apns.mjs.map +1 -1
- package/dist/aws/function-url.cjs +3 -11
- package/dist/aws/function-url.cjs.map +1 -1
- package/dist/aws/function-url.d.cts.map +1 -1
- package/dist/aws/function-url.d.mts.map +1 -1
- package/dist/aws/function-url.mjs +3 -11
- package/dist/aws/function-url.mjs.map +1 -1
- package/dist/aws/index.cjs +2 -3
- package/dist/aws/index.cjs.map +1 -1
- package/dist/aws/index.d.cts +1 -1
- package/dist/aws/index.d.mts +1 -1
- package/dist/aws/index.mjs +2 -3
- package/dist/aws/index.mjs.map +1 -1
- package/dist/aws/instances.cjs +1 -1
- package/dist/aws/instances.cjs.map +1 -1
- package/dist/aws/instances.d.cts +1 -1
- package/dist/aws/instances.d.mts +1 -1
- package/dist/aws/instances.mjs +1 -1
- package/dist/aws/instances.mjs.map +1 -1
- package/dist/cloudflare/bindings.cjs.map +1 -1
- package/dist/cloudflare/bindings.d.cts +0 -19
- package/dist/cloudflare/bindings.d.cts.map +1 -1
- package/dist/cloudflare/bindings.d.mts +0 -19
- package/dist/cloudflare/bindings.d.mts.map +1 -1
- package/dist/cloudflare/bindings.mjs.map +1 -1
- package/dist/cloudflare/index.cjs +0 -5
- package/dist/cloudflare/index.d.cts +4 -6
- package/dist/cloudflare/index.d.mts +4 -6
- package/dist/cloudflare/index.mjs +2 -4
- package/dist/cloudflare/runner.cjs +3 -4
- package/dist/cloudflare/runner.cjs.map +1 -1
- package/dist/cloudflare/runner.d.cts +3 -3
- package/dist/cloudflare/runner.d.cts.map +1 -1
- package/dist/cloudflare/runner.d.mts +3 -3
- package/dist/cloudflare/runner.d.mts.map +1 -1
- package/dist/cloudflare/runner.mjs +3 -4
- package/dist/cloudflare/runner.mjs.map +1 -1
- package/dist/config.cjs.map +1 -1
- package/dist/config.d.cts +1 -1
- package/dist/config.d.mts +1 -1
- package/dist/config.mjs.map +1 -1
- package/dist/engine/condition.cjs +2 -2
- package/dist/engine/condition.cjs.map +1 -1
- package/dist/engine/condition.d.cts +1 -1
- package/dist/engine/condition.d.mts +1 -1
- package/dist/engine/condition.mjs +2 -2
- package/dist/engine/condition.mjs.map +1 -1
- package/dist/engine/ports.d.cts +1 -1
- package/dist/engine/ports.d.mts +1 -1
- package/dist/fcm.cjs +2 -1
- package/dist/fcm.cjs.map +1 -1
- package/dist/fcm.d.cts.map +1 -1
- package/dist/fcm.d.mts.map +1 -1
- package/dist/fcm.mjs +2 -1
- package/dist/fcm.mjs.map +1 -1
- package/dist/host/channels.cjs +2 -1
- package/dist/host/channels.cjs.map +1 -1
- package/dist/host/channels.d.cts +1 -1
- package/dist/host/channels.d.cts.map +1 -1
- package/dist/host/channels.d.mts +1 -1
- package/dist/host/channels.d.mts.map +1 -1
- package/dist/host/channels.mjs +2 -1
- package/dist/host/channels.mjs.map +1 -1
- package/dist/host/execute.d.cts +1 -1
- package/dist/host/execute.d.mts +1 -1
- package/dist/host/http.cjs +3 -4
- package/dist/host/http.cjs.map +1 -1
- package/dist/host/http.d.cts +2 -2
- package/dist/host/http.d.cts.map +1 -1
- package/dist/host/http.d.mts +2 -2
- package/dist/host/http.d.mts.map +1 -1
- package/dist/host/http.mjs +3 -4
- package/dist/host/http.mjs.map +1 -1
- package/dist/host/ingest.cjs +11 -22
- package/dist/host/ingest.cjs.map +1 -1
- package/dist/host/ingest.d.cts +8 -13
- package/dist/host/ingest.d.cts.map +1 -1
- package/dist/host/ingest.d.mts +8 -13
- package/dist/host/ingest.d.mts.map +1 -1
- package/dist/host/ingest.mjs +12 -22
- package/dist/host/ingest.mjs.map +1 -1
- package/dist/host/journey-host.cjs +2 -4
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts +6 -6
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts +6 -6
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +2 -4
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/host/platform.cjs.map +1 -1
- package/dist/host/platform.d.cts +1 -7
- package/dist/host/platform.d.cts.map +1 -1
- package/dist/host/platform.d.mts +1 -7
- package/dist/host/platform.d.mts.map +1 -1
- package/dist/host/platform.mjs.map +1 -1
- package/dist/jwt.cjs +5 -9
- package/dist/jwt.cjs.map +1 -1
- package/dist/jwt.d.cts +1 -1
- package/dist/jwt.d.cts.map +1 -1
- package/dist/jwt.d.mts +1 -1
- package/dist/jwt.d.mts.map +1 -1
- package/dist/jwt.mjs +5 -9
- package/dist/jwt.mjs.map +1 -1
- package/dist/store/facts.cjs +1 -1
- package/dist/store/facts.cjs.map +1 -1
- package/dist/store/facts.mjs +1 -1
- package/dist/store/facts.mjs.map +1 -1
- package/dist/store/index.cjs +0 -2
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +21 -26
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +21 -26
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs +1 -2
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres.cjs +2 -3
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +2 -3
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +2 -3
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +2 -3
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/sql.cjs +2 -1
- package/dist/store/sql.cjs.map +1 -1
- package/dist/store/sql.d.cts.map +1 -1
- package/dist/store/sql.d.mts.map +1 -1
- package/dist/store/sql.mjs +2 -1
- package/dist/store/sql.mjs.map +1 -1
- package/dist/transports/cloudflare-email.d.cts +1 -1
- package/dist/transports/cloudflare-email.d.mts +1 -1
- package/dist/transports/sigv4.cjs +2 -3
- package/dist/transports/sigv4.cjs.map +1 -1
- package/dist/transports/sigv4.d.cts.map +1 -1
- package/dist/transports/sigv4.d.mts.map +1 -1
- package/dist/transports/sigv4.mjs +2 -3
- package/dist/transports/sigv4.mjs.map +1 -1
- package/dist/webpush.cjs +11 -20
- package/dist/webpush.cjs.map +1 -1
- package/dist/webpush.d.cts.map +1 -1
- package/dist/webpush.d.mts.map +1 -1
- package/dist/webpush.mjs +11 -20
- package/dist/webpush.mjs.map +1 -1
- package/package.json +1 -3
- package/dist/cloudflare/d1-env.cjs +0 -17
- package/dist/cloudflare/d1-env.cjs.map +0 -1
- package/dist/cloudflare/d1-env.d.cts +0 -12
- package/dist/cloudflare/d1-env.d.cts.map +0 -1
- package/dist/cloudflare/d1-env.d.mts +0 -12
- package/dist/cloudflare/d1-env.d.mts.map +0 -1
- package/dist/cloudflare/d1-env.mjs +0 -16
- package/dist/cloudflare/d1-env.mjs.map +0 -1
- package/dist/cloudflare/schema.sql +0 -148
- package/dist/store/d1.cjs +0 -245
- package/dist/store/d1.cjs.map +0 -1
- package/dist/store/d1.d.cts +0 -73
- package/dist/store/d1.d.cts.map +0 -1
- package/dist/store/d1.d.mts +0 -73
- package/dist/store/d1.d.mts.map +0 -1
- package/dist/store/d1.mjs +0 -244
- package/dist/store/d1.mjs.map +0 -1
- package/src/cloudflare/schema.sql +0 -148
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ versioned IR, and executed by a single generic runner: the IR is data, so one
|
|
|
5
5
|
deployed Worker runs every journey. Events and profile fields are typed by a
|
|
6
6
|
shared Zod contract, messages are typed templates (React email, push, Slack,
|
|
7
7
|
Discord), and the host is a Cloudflare Worker on Cloudflare Workflows, with its
|
|
8
|
-
data in
|
|
8
|
+
data in your own Postgres.
|
|
9
9
|
|
|
10
10
|
```ts
|
|
11
11
|
const welcome = workflow({ id: 'welcome', trigger: trigger.event(e.sign_up) })
|
|
@@ -189,8 +189,7 @@ export const AcmeJourneyRunner = journeys.Runner;
|
|
|
189
189
|
export default { fetch: journeys.fetch, scheduled: journeys.scheduled };
|
|
190
190
|
```
|
|
191
191
|
|
|
192
|
-
|
|
193
|
-
[Cloudflare setup](#cloudflare-setup)). On Postgres, create the engine's tables
|
|
192
|
+
`store` names the engine's Postgres (here through Hyperdrive); create its tables
|
|
194
193
|
first (see [Postgres](#postgres)).
|
|
195
194
|
|
|
196
195
|
```jsonc
|
|
@@ -742,19 +741,15 @@ backward-compatible changes, so a new required prop means a new id.
|
|
|
742
741
|
|
|
743
742
|
## Cloudflare setup
|
|
744
743
|
|
|
745
|
-
The
|
|
744
|
+
The Worker needs two bindings, named as in `JourneyEnv` and the quick start's `wrangler.jsonc`:
|
|
746
745
|
|
|
747
|
-
| Binding
|
|
748
|
-
|
|
|
749
|
-
| `
|
|
750
|
-
| `
|
|
751
|
-
|
|
752
|
-
The compiled IR lives in D1 with the routes, never in a KV namespace: KV is eventually consistent
|
|
753
|
-
while the routes in D1 are not, so an instance started in another colo right after a deploy could
|
|
754
|
-
miss its IR.
|
|
746
|
+
| Binding | Type | Holds |
|
|
747
|
+
| ------------ | ---------- | ----------------------------------------------------------- |
|
|
748
|
+
| `JOURNEY` | Workflow | The `JourneyRunner` entrypoint |
|
|
749
|
+
| `HYPERDRIVE` | Hyperdrive | The connection to the engine's Postgres (`postgresJsStore`) |
|
|
755
750
|
|
|
756
751
|
The Worker needs the `nodejs_compat` compatibility flag (the bearer check uses
|
|
757
|
-
`node:crypto`'s `timingSafeEqual`).
|
|
752
|
+
`node:crypto`'s `timingSafeEqual`). Add the `EMAIL` send_email binding
|
|
758
753
|
when it sends through Cloudflare Email (`cloudflareEmailTransport({ binding: env.EMAIL })`), and a `triggers.crons` entry with `scheduled`
|
|
759
754
|
exported to run the segment sweep.
|
|
760
755
|
|
|
@@ -780,24 +775,9 @@ window to come back for and no second pass; the tombstone stays, so a late
|
|
|
780
775
|
write cannot bring the user back.
|
|
781
776
|
|
|
782
777
|
One database per host. The routing tables are global (`triggers`, `segments`, the live
|
|
783
|
-
`deployment` row), so two Workers on one
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
The D1 schema ships with the package. Apply it before the first deploy:
|
|
788
|
-
|
|
789
|
-
```bash
|
|
790
|
-
wrangler d1 execute <database> --file node_modules/@shware/workflow/dist/cloudflare/schema.sql
|
|
791
|
-
```
|
|
792
|
-
|
|
793
|
-
Nothing in the adapter creates these tables at runtime, so this step is not
|
|
794
|
-
optional. The same file lives at `src/cloudflare/schema.sql` in this repo and is
|
|
795
|
-
the single source for both.
|
|
796
|
-
|
|
797
|
-
There are no migrations, and re-applying the file is not one: every statement is
|
|
798
|
-
`CREATE TABLE IF NOT EXISTS`, so an existing database keeps its old tables and
|
|
799
|
-
the first insert needing a new column fails. Recreate it instead. The demo has
|
|
800
|
-
`pnpm db:reset`. The `events` key is `(user_id, id)`, and `workflow_ir` holds the compiled IR.
|
|
778
|
+
`deployment` row), so two Workers on one Postgres schema would swap each other's routes on
|
|
779
|
+
every deploy: give staging and production their own schema (`database: { schema }`) or
|
|
780
|
+
database.
|
|
801
781
|
|
|
802
782
|
### Cloudflare deployment notes
|
|
803
783
|
|
|
@@ -817,8 +797,8 @@ the first insert needing a new column fails. Recreate it instead. The demo has
|
|
|
817
797
|
- A completed instance's state is kept 30 days.
|
|
818
798
|
- **Authorization.** Set `API_TOKEN` with `wrangler secret put`, or pass `authorize` (below).
|
|
819
799
|
The tracking routes (`/t/...`) stay public by design.
|
|
820
|
-
- **Deploying.** With the `CF_VERSION_METADATA` binding
|
|
821
|
-
|
|
800
|
+
- **Deploying.** With the `CF_VERSION_METADATA` binding, each `wrangler deploy` makes its own
|
|
801
|
+
bundle live (see [Postgres](#postgres)); `POST /bundle/deploy` forces it.
|
|
822
802
|
- **APNs in development.** `wrangler dev` (workerd) cannot make HTTP/2 subrequests: run
|
|
823
803
|
`@shware/workflow/apns-relay` and point `APNS_RELAY_ORIGIN` at it. A deployed Worker needs no
|
|
824
804
|
relay.
|
|
@@ -1068,8 +1048,7 @@ that version serves its first request, whatever share of traffic it has.
|
|
|
1068
1048
|
|
|
1069
1049
|
`wrangler rollback` restores an older version, which does not apply itself over
|
|
1070
1050
|
the newer one. Call `POST /bundle/deploy` after a rollback: it applies the
|
|
1071
|
-
running version's bundle whatever is live.
|
|
1072
|
-
on D1 every deploy is that call.
|
|
1051
|
+
running version's bundle whatever is live.
|
|
1073
1052
|
|
|
1074
1053
|
On Postgres the engine also keeps:
|
|
1075
1054
|
|
|
@@ -1154,8 +1133,8 @@ is a design question this package does not yet answer.
|
|
|
1154
1133
|
| ------------------------------ | -------------------------------------------------------------------------------------------------- |
|
|
1155
1134
|
| `@shware/workflow` | The DSL, `defineContract`, `defineConfig`, `compileConfig`, `compileBundle`, `plan`, templates, IR |
|
|
1156
1135
|
| `@shware/workflow/engine` | The interpreter (`runJourney`) and its ports, condition evaluation, bucketing |
|
|
1157
|
-
| `@shware/workflow/cloudflare` | `journeyWorker`, `JourneyRunner`, `handleRequest`, the
|
|
1158
|
-
| `@shware/workflow/store` | The `JourneyStore` port,
|
|
1136
|
+
| `@shware/workflow/cloudflare` | `journeyWorker`, `JourneyRunner`, `handleRequest`, the ingest functions, senders, the store |
|
|
1137
|
+
| `@shware/workflow/store` | The `JourneyStore` port, the Postgres store, `renderPostgresDdl`, `renderDrizzleSchema` |
|
|
1159
1138
|
| `@shware/workflow/client` | `JourneyClient`, the producer side of the HTTP API |
|
|
1160
1139
|
| `@shware/workflow/postgres-js` | `postgresJsStore`, the Postgres store over postgres.js (peer `postgres`) |
|
|
1161
1140
|
| `@shware/workflow/react-email` | `registryRenderer`, the email renderer over the registry (peers `react`, `react-dom`) |
|
|
@@ -1165,7 +1144,6 @@ is a design question this package does not yet answer.
|
|
|
1165
1144
|
| `@shware/workflow/fcm` | `FcmPushSender` over an `FcmAccessToken`, FCM HTTP v1 for Android |
|
|
1166
1145
|
| `@shware/workflow/webpush` | `WebPushSender` over a `VapidSigner`, `generateVapidKeys`; standard Web Push with VAPID |
|
|
1167
1146
|
| `@shware/workflow/webhooks` | `SlackWebhookSender` and `DiscordWebhookSender` |
|
|
1168
|
-
| `@shware/workflow/schema.sql` | The D1 schema file |
|
|
1169
1147
|
|
|
1170
1148
|
## Testing
|
|
1171
1149
|
|
package/dist/apns.cjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_engine_retry = require("./engine/retry.cjs");
|
|
3
3
|
const require_jwt = require("./jwt.cjs");
|
|
4
|
+
let node_buffer = require("node:buffer");
|
|
4
5
|
//#region src/apns.ts
|
|
5
6
|
const APNS_HOSTS = {
|
|
6
7
|
sandbox: "https://api.sandbox.push.apple.com",
|
|
@@ -49,7 +50,7 @@ async function apnsIdFromKey(idempotencyKey) {
|
|
|
49
50
|
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(idempotencyKey))).slice(0, 16);
|
|
50
51
|
bytes[6] = bytes[6] & 15 | 128;
|
|
51
52
|
bytes[8] = bytes[8] & 63 | 128;
|
|
52
|
-
const hex =
|
|
53
|
+
const hex = node_buffer.Buffer.from(bytes).toString("hex");
|
|
53
54
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
54
55
|
}
|
|
55
56
|
/** Apple: reuse a provider token for at least 20 minutes, never past 60. */
|
|
@@ -188,7 +189,7 @@ async function signApnsJwt(key, nowMs = Date.now()) {
|
|
|
188
189
|
function apnsKeyFromBase64(value) {
|
|
189
190
|
let pem;
|
|
190
191
|
try {
|
|
191
|
-
pem =
|
|
192
|
+
pem = node_buffer.Buffer.from(value, "base64").toString();
|
|
192
193
|
} catch (error) {
|
|
193
194
|
throw new require_engine_retry.PermanentError("APNs key is not base64 (expected the .p8 file, base64-encoded)", { cause: error });
|
|
194
195
|
}
|
package/dist/apns.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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":";;;;AAmDA,MAAa,aAAa;CACxB,SAAS;CACT,YAAY;AACd;;;;;;;;;;;;;;;;;;;;AAqBA,MAAa,qCAA0C,IAAI,IAAI,CAAC,gBAAgB,cAAc,CAAC;;AAG/F,MAAM,wCAA6C,IAAI,IAAI;CACzD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC;;;;;;;;AASD,eAAsB,cAAc,gBAAyC;CAI3E,MAAM,QAAQ,IAHK,WACjB,MAAM,OAAO,OAAO,OAAO,WAAW,IAAI,YAAY,CAAC,CAAC,OAAO,cAAc,CAAC,CAE7D,CAAC,CAAC,MAAM,GAAG,EAAE;CAChC,MAAM,KAAM,MAAM,KAAK,KAAQ;CAC/B,MAAM,KAAM,MAAM,KAAK,KAAQ;CAC/B,MAAM,MAAM,MAAM,KAAK,QAAQ,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;CAC7E,OAAO,GAAG,IAAI,MAAM,GAAG,CAAC,EAAE,GAAG,IAAI,MAAM,GAAG,EAAE,EAAE,GAAG,IAAI,MAAM,IAAI,EAAE,EAAE,GAAG,IAAI,MAAM,IAAI,EAAE,EAAE,GAAG,IAAI,MAAM,EAAE;AACzG;;AAGA,MAAM,eAAe;;;;;;;;AASrB,IAAa,oBAAb,MAA+B;CAIA;CAH7B;CACA;CAEA,YAAY,SAAoD;EAAnC,KAAA,UAAA;CAAoC;;CAGjE,MAAM,MAAM,MAAM,KAAK,IAAI,GAAoB;EAC7C,IAAI,KAAK,WAAW,KAAA,KAAa,MAAM,KAAK,OAAO,WAAW,cAC5D,OAAO,KAAK,OAAO;EAErB,KAAK,YAAY,YAAY,KAAK,SAAS,GAAG,CAAC,CAC5C,MAAM,UAAU;GACf,KAAK,SAAS;IAAE;IAAO,UAAU;GAAI;GACrC,OAAO;EACT,CAAC,CAAC,CACD,cAAc;GACb,KAAK,UAAU,KAAA;EACjB,CAAC;EACH,OAAO,KAAK;CACd;;CAGA,aAAmB;EACjB,KAAK,SAAS,KAAA;CAChB;AACF;AAEA,IAAa,iBAAb,MAAqD;CACtB;CAA7B,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;EACtC,MAAM,UAAU,KAAK,QAAQ,SAAS;EAEtC,MAAM,SAAS,MAAM,cAAc,QAAQ,cAAc;EACzD,MAAM,WAAW,MAAM,QAAQ,GAAG,OAAO,YAAY,QAAQ,aAAa;GACxE,QAAQ;GACR,SAAS;IACP,eAAe,UAAU,MAAM,KAAK,QAAQ,YAAY,MAAM;IAC9D,cAAc,KAAK,QAAQ;IAC3B,kBAAkB;IAClB,iBAAiB;IAGjB,WAAW;IACX,oBAAoB;IACpB,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,EAAE,WAAW;EAEnB,MAAM,SAAS,WAAW,IAAI,MAAM,WAAW,MAAM,iBAAiB;EACtE,IAAI,WAAW,OAAO,mBAAmB,IAAI,MAAM,GAAG;GAGpD,QAAQ,KAAK,8BAA8B,QAAQ,OAAO,IAAI,QAAQ;GACtE,OAAO;IAAE,WAAW;IAAO;GAAO;EACpC;EACA,IAAI,WAAW,kBAAkB;GAE/B,QAAQ,KAAK,2BAA2B,QAAQ,OAAO,IAAI,OAAO,GAAG,QAAQ;GAC7E,OAAO;IAAE,WAAW;IAAO;GAAO;EACpC;EACA,IAAI,WAAW,wBAAwB;GAErC,KAAK,QAAQ,YAAY,WAAW;GACpC,MAAM,IAAI,MACR,qBAAqB,OAAO,GAAG,OAAO,wCACxC;EACF;EACA,IAAI,sBAAsB,IAAI,MAAM,GAClC,MAAM,IAAIA,qBAAAA,eACR,uCAAuC,KAAK,QAAQ,MAAM,KAAK,KAAK,QAAQ,YAAY,KAAK,OAAO,GAAG,QACzG;EAEF,IAAIC,qBAAAA,kBAAkB,MAAM,GAC1B,MAAM,IAAI,MAAM,qBAAqB,OAAO,GAAG,UAAU,MAAM;EAEjE,MAAM,IAAID,qBAAAA,eAAe,6BAA6B,OAAO,GAAG,UAAU,MAAM;CAClF;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,SAASE,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,IAAI;CACJ,IAAI;EACF,MAAM,KAAK,KAAK;CAClB,SAAS,OAAO;EAEd,MAAM,IAAIF,qBAAAA,eAAe,kEAAkE,EACzF,OAAO,MACT,CAAC;CACH;CACA,IAAI,CAAC,IAAI,SAAS,6BAA6B,GAC7C,MAAM,IAAIA,qBAAAA,eAAe,0CAA0C;CAErE,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"apns.cjs","names":["Buffer","PermanentError","isRetryableStatus","base64url","pemToDer"],"sources":["../src/apns.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport 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 = Buffer.from(bytes).toString('hex');\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 = Buffer.from(value, 'base64').toString();\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.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":";;;;;;;;;;;;;;;;;iBAoBiB;;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"}
|
package/dist/apns.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"apns.d.mts","names":[],"sources":["../src/apns.ts"],"mappings":";;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"apns.d.mts","names":[],"sources":["../src/apns.ts"],"mappings":";;;;;;;;;;;;;;;;;iBAoBiB;;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"}
|
package/dist/apns.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PermanentError, isRetryableStatus } from "./engine/retry.mjs";
|
|
2
2
|
import { base64url, pemToDer } from "./jwt.mjs";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
3
4
|
//#region src/apns.ts
|
|
4
5
|
const APNS_HOSTS = {
|
|
5
6
|
sandbox: "https://api.sandbox.push.apple.com",
|
|
@@ -48,7 +49,7 @@ async function apnsIdFromKey(idempotencyKey) {
|
|
|
48
49
|
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(idempotencyKey))).slice(0, 16);
|
|
49
50
|
bytes[6] = bytes[6] & 15 | 128;
|
|
50
51
|
bytes[8] = bytes[8] & 63 | 128;
|
|
51
|
-
const hex =
|
|
52
|
+
const hex = Buffer.from(bytes).toString("hex");
|
|
52
53
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
53
54
|
}
|
|
54
55
|
/** Apple: reuse a provider token for at least 20 minutes, never past 60. */
|
|
@@ -187,7 +188,7 @@ async function signApnsJwt(key, nowMs = Date.now()) {
|
|
|
187
188
|
function apnsKeyFromBase64(value) {
|
|
188
189
|
let pem;
|
|
189
190
|
try {
|
|
190
|
-
pem =
|
|
191
|
+
pem = Buffer.from(value, "base64").toString();
|
|
191
192
|
} catch (error) {
|
|
192
193
|
throw new PermanentError("APNs key is not base64 (expected the .p8 file, base64-encoded)", { cause: error });
|
|
193
194
|
}
|
package/dist/apns.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"apns.mjs","names":[],"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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"}
|
|
1
|
+
{"version":3,"file":"apns.mjs","names":[],"sources":["../src/apns.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport 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 = Buffer.from(bytes).toString('hex');\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 = Buffer.from(value, 'base64').toString();\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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"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_buffer = require("node:buffer");
|
|
2
3
|
//#region src/aws/function-url.ts
|
|
3
4
|
/**
|
|
4
5
|
* An HTTP request, told apart by the event's own top-level shape — the Function URL wraps
|
|
@@ -9,17 +10,8 @@ function isFunctionUrlEvent(event) {
|
|
|
9
10
|
const { requestContext, rawPath } = event;
|
|
10
11
|
return typeof rawPath === "string" && typeof requestContext === "object" && requestContext !== null && typeof requestContext.http === "object" && requestContext.http !== null && typeof requestContext.http.method === "string";
|
|
11
12
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const bytes = new Uint8Array(binary.length);
|
|
15
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
16
|
-
return bytes;
|
|
17
|
-
}
|
|
18
|
-
function toBase64(bytes) {
|
|
19
|
-
let binary = "";
|
|
20
|
-
for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
|
|
21
|
-
return btoa(binary);
|
|
22
|
-
}
|
|
13
|
+
const fromBase64 = (value) => new Uint8Array(node_buffer.Buffer.from(value, "base64"));
|
|
14
|
+
const toBase64 = (bytes) => node_buffer.Buffer.from(bytes).toString("base64");
|
|
23
15
|
function toRequest(event) {
|
|
24
16
|
const method = event.requestContext.http.method.toUpperCase();
|
|
25
17
|
const headers = new Headers();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"function-url.cjs","names":[],"sources":["../../src/aws/function-url.ts"],"sourcesContent":["/**\n * Lambda Function URL events (payload format 2.0) as fetch `Request`s, and `Response`s back as\n * the result Lambda turns into the HTTP response — so the host's HTTP API runs unchanged.\n */\n\nexport interface FunctionUrlEvent {\n version?: string;\n rawPath: string;\n rawQueryString?: string;\n headers?: Record<string, string | undefined>;\n cookies?: string[];\n body?: string;\n isBase64Encoded?: boolean;\n requestContext: {\n domainName?: string;\n http: { method: string; sourceIp?: string; userAgent?: string };\n };\n}\n\nexport interface FunctionUrlResult {\n statusCode: number;\n headers: Record<string, string>;\n cookies?: string[];\n body: string;\n isBase64Encoded: boolean;\n}\n\n/**\n * An HTTP request, told apart by the event's own top-level shape — the Function URL wraps\n * every request in `requestContext.http`, so no request body can pass for anything else.\n */\nexport function isFunctionUrlEvent(event: unknown): event is FunctionUrlEvent {\n if (typeof event !== 'object' || event === null) return false;\n // Untrusted until checked: every level may be missing\n const { requestContext, rawPath } = event as {\n requestContext?: { http?: { method?: unknown } | null } | null;\n rawPath?: unknown;\n };\n return (\n typeof rawPath === 'string' &&\n typeof requestContext === 'object' &&\n requestContext !== null &&\n typeof requestContext.http === 'object' &&\n requestContext.http !== null &&\n typeof requestContext.http.method === 'string'\n );\n}\n\
|
|
1
|
+
{"version":3,"file":"function-url.cjs","names":["Buffer"],"sources":["../../src/aws/function-url.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\n/**\n * Lambda Function URL events (payload format 2.0) as fetch `Request`s, and `Response`s back as\n * the result Lambda turns into the HTTP response — so the host's HTTP API runs unchanged.\n */\n\nexport interface FunctionUrlEvent {\n version?: string;\n rawPath: string;\n rawQueryString?: string;\n headers?: Record<string, string | undefined>;\n cookies?: string[];\n body?: string;\n isBase64Encoded?: boolean;\n requestContext: {\n domainName?: string;\n http: { method: string; sourceIp?: string; userAgent?: string };\n };\n}\n\nexport interface FunctionUrlResult {\n statusCode: number;\n headers: Record<string, string>;\n cookies?: string[];\n body: string;\n isBase64Encoded: boolean;\n}\n\n/**\n * An HTTP request, told apart by the event's own top-level shape — the Function URL wraps\n * every request in `requestContext.http`, so no request body can pass for anything else.\n */\nexport function isFunctionUrlEvent(event: unknown): event is FunctionUrlEvent {\n if (typeof event !== 'object' || event === null) return false;\n // Untrusted until checked: every level may be missing\n const { requestContext, rawPath } = event as {\n requestContext?: { http?: { method?: unknown } | null } | null;\n rawPath?: unknown;\n };\n return (\n typeof rawPath === 'string' &&\n typeof requestContext === 'object' &&\n requestContext !== null &&\n typeof requestContext.http === 'object' &&\n requestContext.http !== null &&\n typeof requestContext.http.method === 'string'\n );\n}\n\nconst fromBase64 = (value: string): Uint8Array => new Uint8Array(Buffer.from(value, 'base64'));\nconst toBase64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString('base64');\n\nexport function toRequest(event: FunctionUrlEvent): Request {\n const method = event.requestContext.http.method.toUpperCase();\n const headers = new Headers();\n for (const [name, value] of Object.entries(event.headers ?? {})) {\n if (value !== undefined) headers.set(name, value);\n }\n if (event.cookies !== undefined && event.cookies.length > 0) {\n headers.set('cookie', event.cookies.join('; '));\n }\n const host = event.requestContext.domainName ?? headers.get('host') ?? 'localhost';\n const query =\n event.rawQueryString === undefined || event.rawQueryString === ''\n ? ''\n : `?${event.rawQueryString}`;\n const hasBody = method !== 'GET' && method !== 'HEAD' && event.body !== undefined;\n return new Request(`https://${host}${event.rawPath}${query}`, {\n method,\n headers,\n ...(hasBody\n ? { body: event.isBase64Encoded === true ? fromBase64(event.body ?? '') : event.body }\n : {}),\n });\n}\n\n/** Content types sent as text; everything else (a tracking pixel) goes back base64. */\nconst TEXTUAL =\n /^(text\\/|application\\/(json|javascript|xml|x-www-form-urlencoded)|image\\/svg\\+xml)/i;\n\nexport async function fromResponse(response: Response, method: string): Promise<FunctionUrlResult> {\n const headers: Record<string, string> = {};\n const cookies: string[] = [];\n response.headers.forEach((value, name) => {\n if (name === 'set-cookie') cookies.push(value);\n else headers[name] = value;\n });\n const contentType = response.headers.get('content-type') ?? '';\n const head = method.toUpperCase() === 'HEAD';\n const bytes = head ? new Uint8Array() : new Uint8Array(await response.arrayBuffer());\n const textual = bytes.length === 0 || TEXTUAL.test(contentType);\n return {\n statusCode: response.status,\n headers,\n ...(cookies.length === 0 ? {} : { cookies }),\n body: textual ? new TextDecoder().decode(bytes) : toBase64(bytes),\n isBase64Encoded: !textual,\n };\n}\n"],"mappings":";;;;;;;AAgCA,SAAgB,mBAAmB,OAA2C;CAC5E,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CAExD,MAAM,EAAE,gBAAgB,YAAY;CAIpC,OACE,OAAO,YAAY,YACnB,OAAO,mBAAmB,YAC1B,mBAAmB,QACnB,OAAO,eAAe,SAAS,YAC/B,eAAe,SAAS,QACxB,OAAO,eAAe,KAAK,WAAW;AAE1C;AAEA,MAAM,cAAc,UAA8B,IAAI,WAAWA,YAAAA,OAAO,KAAK,OAAO,QAAQ,CAAC;AAC7F,MAAM,YAAY,UAA8BA,YAAAA,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,QAAQ;AAEpF,SAAgB,UAAU,OAAkC;CAC1D,MAAM,SAAS,MAAM,eAAe,KAAK,OAAO,YAAY;CAC5D,MAAM,UAAU,IAAI,QAAQ;CAC5B,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,MAAM,WAAW,CAAC,CAAC,GAC5D,IAAI,UAAU,KAAA,GAAW,QAAQ,IAAI,MAAM,KAAK;CAElD,IAAI,MAAM,YAAY,KAAA,KAAa,MAAM,QAAQ,SAAS,GACxD,QAAQ,IAAI,UAAU,MAAM,QAAQ,KAAK,IAAI,CAAC;CAEhD,MAAM,OAAO,MAAM,eAAe,cAAc,QAAQ,IAAI,MAAM,KAAK;CACvE,MAAM,QACJ,MAAM,mBAAmB,KAAA,KAAa,MAAM,mBAAmB,KAC3D,KACA,IAAI,MAAM;CAChB,MAAM,UAAU,WAAW,SAAS,WAAW,UAAU,MAAM,SAAS,KAAA;CACxE,OAAO,IAAI,QAAQ,WAAW,OAAO,MAAM,UAAU,SAAS;EAC5D;EACA;EACA,GAAI,UACA,EAAE,MAAM,MAAM,oBAAoB,OAAO,WAAW,MAAM,QAAQ,EAAE,IAAI,MAAM,KAAK,IACnF,CAAC;CACP,CAAC;AACH;;AAGA,MAAM,UACJ;AAEF,eAAsB,aAAa,UAAoB,QAA4C;CACjG,MAAM,UAAkC,CAAC;CACzC,MAAM,UAAoB,CAAC;CAC3B,SAAS,QAAQ,SAAS,OAAO,SAAS;EACxC,IAAI,SAAS,cAAc,QAAQ,KAAK,KAAK;OACxC,QAAQ,QAAQ;CACvB,CAAC;CACD,MAAM,cAAc,SAAS,QAAQ,IAAI,cAAc,KAAK;CAE5D,MAAM,QADO,OAAO,YAAY,MAAM,yBACjB,IAAI,WAAW,IAAI,IAAI,WAAW,MAAM,SAAS,YAAY,CAAC;CACnF,MAAM,UAAU,MAAM,WAAW,KAAK,QAAQ,KAAK,WAAW;CAC9D,OAAO;EACL,YAAY,SAAS;EACrB;EACA,GAAI,QAAQ,WAAW,IAAI,CAAC,IAAI,EAAE,QAAQ;EAC1C,MAAM,UAAU,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,IAAI,SAAS,KAAK;EAChE,iBAAiB,CAAC;CACpB;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"function-url.d.cts","names":[],"sources":["../../src/aws/function-url.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"function-url.d.cts","names":[],"sources":["../../src/aws/function-url.ts"],"mappings":";;;;;iBAMiB;EACf;EACA;EACA;EACA,UAAU;EACV;EACA;EACA;EACA;IACE;IACA;MAAQ;MAAgB;MAAmB;;;;iBAI9B;EACf;EACA,SAAS;EACT;EACA;EACA;;;;;;wBAOc,mBAAmB,iBAAiB,SAAS;wBAoB7C,UAAU,OAAO,mBAAmB;wBA4B9B,aAAa,UAAU,UAAU,iBAAiB,QAAQ"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"function-url.d.mts","names":[],"sources":["../../src/aws/function-url.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"function-url.d.mts","names":[],"sources":["../../src/aws/function-url.ts"],"mappings":";;;;;iBAMiB;EACf;EACA;EACA;EACA,UAAU;EACV;EACA;EACA;EACA;IACE;IACA;MAAQ;MAAgB;MAAmB;;;;iBAI9B;EACf;EACA,SAAS;EACT;EACA;EACA;;;;;;wBAOc,mBAAmB,iBAAiB,SAAS;wBAoB7C,UAAU,OAAO,mBAAmB;wBA4B9B,aAAa,UAAU,UAAU,iBAAiB,QAAQ"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
1
2
|
//#region src/aws/function-url.ts
|
|
2
3
|
/**
|
|
3
4
|
* An HTTP request, told apart by the event's own top-level shape — the Function URL wraps
|
|
@@ -8,17 +9,8 @@ function isFunctionUrlEvent(event) {
|
|
|
8
9
|
const { requestContext, rawPath } = event;
|
|
9
10
|
return typeof rawPath === "string" && typeof requestContext === "object" && requestContext !== null && typeof requestContext.http === "object" && requestContext.http !== null && typeof requestContext.http.method === "string";
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const bytes = new Uint8Array(binary.length);
|
|
14
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
15
|
-
return bytes;
|
|
16
|
-
}
|
|
17
|
-
function toBase64(bytes) {
|
|
18
|
-
let binary = "";
|
|
19
|
-
for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
|
|
20
|
-
return btoa(binary);
|
|
21
|
-
}
|
|
12
|
+
const fromBase64 = (value) => new Uint8Array(Buffer.from(value, "base64"));
|
|
13
|
+
const toBase64 = (bytes) => Buffer.from(bytes).toString("base64");
|
|
22
14
|
function toRequest(event) {
|
|
23
15
|
const method = event.requestContext.http.method.toUpperCase();
|
|
24
16
|
const headers = new Headers();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"function-url.mjs","names":[],"sources":["../../src/aws/function-url.ts"],"sourcesContent":["/**\n * Lambda Function URL events (payload format 2.0) as fetch `Request`s, and `Response`s back as\n * the result Lambda turns into the HTTP response — so the host's HTTP API runs unchanged.\n */\n\nexport interface FunctionUrlEvent {\n version?: string;\n rawPath: string;\n rawQueryString?: string;\n headers?: Record<string, string | undefined>;\n cookies?: string[];\n body?: string;\n isBase64Encoded?: boolean;\n requestContext: {\n domainName?: string;\n http: { method: string; sourceIp?: string; userAgent?: string };\n };\n}\n\nexport interface FunctionUrlResult {\n statusCode: number;\n headers: Record<string, string>;\n cookies?: string[];\n body: string;\n isBase64Encoded: boolean;\n}\n\n/**\n * An HTTP request, told apart by the event's own top-level shape — the Function URL wraps\n * every request in `requestContext.http`, so no request body can pass for anything else.\n */\nexport function isFunctionUrlEvent(event: unknown): event is FunctionUrlEvent {\n if (typeof event !== 'object' || event === null) return false;\n // Untrusted until checked: every level may be missing\n const { requestContext, rawPath } = event as {\n requestContext?: { http?: { method?: unknown } | null } | null;\n rawPath?: unknown;\n };\n return (\n typeof rawPath === 'string' &&\n typeof requestContext === 'object' &&\n requestContext !== null &&\n typeof requestContext.http === 'object' &&\n requestContext.http !== null &&\n typeof requestContext.http.method === 'string'\n );\n}\n\
|
|
1
|
+
{"version":3,"file":"function-url.mjs","names":[],"sources":["../../src/aws/function-url.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\n/**\n * Lambda Function URL events (payload format 2.0) as fetch `Request`s, and `Response`s back as\n * the result Lambda turns into the HTTP response — so the host's HTTP API runs unchanged.\n */\n\nexport interface FunctionUrlEvent {\n version?: string;\n rawPath: string;\n rawQueryString?: string;\n headers?: Record<string, string | undefined>;\n cookies?: string[];\n body?: string;\n isBase64Encoded?: boolean;\n requestContext: {\n domainName?: string;\n http: { method: string; sourceIp?: string; userAgent?: string };\n };\n}\n\nexport interface FunctionUrlResult {\n statusCode: number;\n headers: Record<string, string>;\n cookies?: string[];\n body: string;\n isBase64Encoded: boolean;\n}\n\n/**\n * An HTTP request, told apart by the event's own top-level shape — the Function URL wraps\n * every request in `requestContext.http`, so no request body can pass for anything else.\n */\nexport function isFunctionUrlEvent(event: unknown): event is FunctionUrlEvent {\n if (typeof event !== 'object' || event === null) return false;\n // Untrusted until checked: every level may be missing\n const { requestContext, rawPath } = event as {\n requestContext?: { http?: { method?: unknown } | null } | null;\n rawPath?: unknown;\n };\n return (\n typeof rawPath === 'string' &&\n typeof requestContext === 'object' &&\n requestContext !== null &&\n typeof requestContext.http === 'object' &&\n requestContext.http !== null &&\n typeof requestContext.http.method === 'string'\n );\n}\n\nconst fromBase64 = (value: string): Uint8Array => new Uint8Array(Buffer.from(value, 'base64'));\nconst toBase64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString('base64');\n\nexport function toRequest(event: FunctionUrlEvent): Request {\n const method = event.requestContext.http.method.toUpperCase();\n const headers = new Headers();\n for (const [name, value] of Object.entries(event.headers ?? {})) {\n if (value !== undefined) headers.set(name, value);\n }\n if (event.cookies !== undefined && event.cookies.length > 0) {\n headers.set('cookie', event.cookies.join('; '));\n }\n const host = event.requestContext.domainName ?? headers.get('host') ?? 'localhost';\n const query =\n event.rawQueryString === undefined || event.rawQueryString === ''\n ? ''\n : `?${event.rawQueryString}`;\n const hasBody = method !== 'GET' && method !== 'HEAD' && event.body !== undefined;\n return new Request(`https://${host}${event.rawPath}${query}`, {\n method,\n headers,\n ...(hasBody\n ? { body: event.isBase64Encoded === true ? fromBase64(event.body ?? '') : event.body }\n : {}),\n });\n}\n\n/** Content types sent as text; everything else (a tracking pixel) goes back base64. */\nconst TEXTUAL =\n /^(text\\/|application\\/(json|javascript|xml|x-www-form-urlencoded)|image\\/svg\\+xml)/i;\n\nexport async function fromResponse(response: Response, method: string): Promise<FunctionUrlResult> {\n const headers: Record<string, string> = {};\n const cookies: string[] = [];\n response.headers.forEach((value, name) => {\n if (name === 'set-cookie') cookies.push(value);\n else headers[name] = value;\n });\n const contentType = response.headers.get('content-type') ?? '';\n const head = method.toUpperCase() === 'HEAD';\n const bytes = head ? new Uint8Array() : new Uint8Array(await response.arrayBuffer());\n const textual = bytes.length === 0 || TEXTUAL.test(contentType);\n return {\n statusCode: response.status,\n headers,\n ...(cookies.length === 0 ? {} : { cookies }),\n body: textual ? new TextDecoder().decode(bytes) : toBase64(bytes),\n isBase64Encoded: !textual,\n };\n}\n"],"mappings":";;;;;;AAgCA,SAAgB,mBAAmB,OAA2C;CAC5E,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CAExD,MAAM,EAAE,gBAAgB,YAAY;CAIpC,OACE,OAAO,YAAY,YACnB,OAAO,mBAAmB,YAC1B,mBAAmB,QACnB,OAAO,eAAe,SAAS,YAC/B,eAAe,SAAS,QACxB,OAAO,eAAe,KAAK,WAAW;AAE1C;AAEA,MAAM,cAAc,UAA8B,IAAI,WAAW,OAAO,KAAK,OAAO,QAAQ,CAAC;AAC7F,MAAM,YAAY,UAA8B,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,QAAQ;AAEpF,SAAgB,UAAU,OAAkC;CAC1D,MAAM,SAAS,MAAM,eAAe,KAAK,OAAO,YAAY;CAC5D,MAAM,UAAU,IAAI,QAAQ;CAC5B,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,MAAM,WAAW,CAAC,CAAC,GAC5D,IAAI,UAAU,KAAA,GAAW,QAAQ,IAAI,MAAM,KAAK;CAElD,IAAI,MAAM,YAAY,KAAA,KAAa,MAAM,QAAQ,SAAS,GACxD,QAAQ,IAAI,UAAU,MAAM,QAAQ,KAAK,IAAI,CAAC;CAEhD,MAAM,OAAO,MAAM,eAAe,cAAc,QAAQ,IAAI,MAAM,KAAK;CACvE,MAAM,QACJ,MAAM,mBAAmB,KAAA,KAAa,MAAM,mBAAmB,KAC3D,KACA,IAAI,MAAM;CAChB,MAAM,UAAU,WAAW,SAAS,WAAW,UAAU,MAAM,SAAS,KAAA;CACxE,OAAO,IAAI,QAAQ,WAAW,OAAO,MAAM,UAAU,SAAS;EAC5D;EACA;EACA,GAAI,UACA,EAAE,MAAM,MAAM,oBAAoB,OAAO,WAAW,MAAM,QAAQ,EAAE,IAAI,MAAM,KAAK,IACnF,CAAC;CACP,CAAC;AACH;;AAGA,MAAM,UACJ;AAEF,eAAsB,aAAa,UAAoB,QAA4C;CACjG,MAAM,UAAkC,CAAC;CACzC,MAAM,UAAoB,CAAC;CAC3B,SAAS,QAAQ,SAAS,OAAO,SAAS;EACxC,IAAI,SAAS,cAAc,QAAQ,KAAK,KAAK;OACxC,QAAQ,QAAQ;CACvB,CAAC;CACD,MAAM,cAAc,SAAS,QAAQ,IAAI,cAAc,KAAK;CAE5D,MAAM,QADO,OAAO,YAAY,MAAM,yBACjB,IAAI,WAAW,IAAI,IAAI,WAAW,MAAM,SAAS,YAAY,CAAC;CACnF,MAAM,UAAU,MAAM,WAAW,KAAK,QAAQ,KAAK,WAAW;CAC9D,OAAO;EACL,YAAY,SAAS;EACrB;EACA,GAAI,QAAQ,WAAW,IAAI,CAAC,IAAI,EAAE,QAAQ;EAC1C,MAAM,UAAU,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,IAAI,SAAS,KAAK;EAChE,iBAAiB,CAAC;CACpB;AACF"}
|
package/dist/aws/index.cjs
CHANGED
|
@@ -75,8 +75,7 @@ function journeyLambda(options) {
|
|
|
75
75
|
const placeholder = { ...baseEnv };
|
|
76
76
|
invocation.lease ??= host.lease(placeholder);
|
|
77
77
|
const { store } = invocation.lease;
|
|
78
|
-
const executions = store
|
|
79
|
-
if (executions === void 0) throw new Error("journeyLambda needs a store with an execution registry (PostgresJourneyStore)");
|
|
78
|
+
const { executions } = store;
|
|
80
79
|
const functionArn = unqualified(context.lambdaContext.invokedFunctionArn);
|
|
81
80
|
const target = async (contentHash, version) => {
|
|
82
81
|
if (version !== void 0 && /^\d+$/.test(version)) return `${functionArn}:${version}`;
|
|
@@ -167,7 +166,7 @@ function journeyLambda(options) {
|
|
|
167
166
|
}
|
|
168
167
|
const sizes = await context.step("sweep:snapshot", async () => {
|
|
169
168
|
const { store } = open(context);
|
|
170
|
-
return
|
|
169
|
+
return store.reporting.snapshotSegments(Date.now());
|
|
171
170
|
}, once);
|
|
172
171
|
return {
|
|
173
172
|
users,
|
package/dist/aws/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":["createJourneyHost","http2Fetch","longestJourneyMs","sdkLambdaApi","AsyncLocalStorage","AwsInstances","readWithRetry","PermanentError","executeJourney","AwsEngineStep","toRequest","fromResponse","sweepMaintenance","sweepSegmentsPaged","withDurableExecution","isFunctionUrlEvent","isRunInput"],"sources":["../../src/aws/index.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks';\nimport {\n type DurableContext,\n type DurableExecutionInvocationInput,\n type DurableExecutionInvocationOutput,\n withDurableExecution,\n} from '@aws/durable-execution-sdk-js';\nimport { longestJourneyMs } from '../engine/duration';\nimport type { JourneyOutcome } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport { executeJourney } from '../host/execute';\nimport { readWithRetry } from '../host/fresh-read';\nimport {\n SWEEP_PAGE_SIZE,\n type SweepResult,\n sweepMaintenance,\n sweepSegmentsPaged,\n} from '../host/ingest';\nimport { type JourneyHostOptions, createJourneyHost } from '../host/journey-host';\nimport type { JourneyEnv } from '../host/platform';\nimport { http2Fetch } from '../http2-client';\nimport type { BundleVersion, ExecutionRegistry, JourneyStore, StoreLease } from '../store/index';\nimport { AwsEngineStep } from './engine-step';\nimport {\n type FunctionUrlEvent,\n type FunctionUrlResult,\n fromResponse,\n isFunctionUrlEvent,\n toRequest,\n} from './function-url';\nimport { AwsInstances, type RunInput, isRunInput } from './instances';\nimport { type LambdaApi, sdkLambdaApi } from './lambda-api';\n\n/**\n * `@shware/workflow/aws` — the journey host as one Lambda durable function behind a Function\n * URL, on Postgres.\n *\n * The one handler tells three inputs apart by the event's top-level shape: a Function URL\n * request (the HTTP API — ingest, identify, devices, tracking, bundle, preview), an internal run\n * (one journey instance: one durable execution, started by an asynchronous invoke of this\n * function's own numbered version), and a scheduled maintenance tick. An HTTP body can never\n * pass for the other two: the Function URL always wraps it.\n *\n * const journeys = journeyLambda({\n * config,\n * env, // parsed from process.env (zod)\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database),\n * email: (env) => sesEmailTransport({ region: env.AWS_REGION }),\n * });\n * export const handler = journeys.handler;\n */\n\n/** The scheduled tick (EventBridge Scheduler's payload): the segment sweep and maintenance. */\nexport const SWEEP_EVENT_TYPE = '$workflow.sweep';\n\n/** What journeyLambda reads from its env: `JourneyEnv` without the binding it supplies itself. */\nexport type JourneyLambdaEnv = Omit<JourneyEnv, 'JOURNEY' | 'DB' | 'EMAIL'>;\n\nexport interface JourneyLambdaOptions<Env extends JourneyLambdaEnv> extends Omit<\n JourneyHostOptions<Env & JourneyEnv>,\n 'env' | 'store'\n> {\n /** The validated environment (parsed from `process.env` with zod by the app). */\n env: Env;\n /** The Postgres store: AWS needs its execution registry (`postgresJsStore`). */\n store: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /** The Lambda control plane; `@aws-sdk/client-lambda` with the execution role by default. */\n lambda?: LambdaApi;\n /** How long an unconfirmed start is retried (default 12 hours; keep it under the retention). */\n startRetryMs?: number;\n /**\n * How long an erasure waits after stopping an execution before it erases: an invocation\n * running at the stop goes on to its next checkpoint. Set it to at least the function's\n * timeout; 15 minutes (Lambda's maximum) by default.\n */\n stopDrainMs?: number;\n /**\n * The function's `DurableConfig.ExecutionTimeout`, in milliseconds. With it, a workflow that\n * can outlive it (`longestJourneyMs`) fails the handler's creation — a deploy's smoke test —\n * instead of being stopped by the platform midway and recorded as failed.\n */\n executionTimeoutMs?: number;\n}\n\n/**\n * The Lambda invocation context, as far as the handler's signature needs it — declared here so\n * the package's types do not depend on `@types/aws-lambda`. Lambda passes the full object.\n */\nexport interface LambdaContext {\n functionName: string;\n functionVersion: string;\n invokedFunctionArn: string;\n awsRequestId: string;\n getRemainingTimeInMillis(): number;\n}\n\nexport interface JourneyLambda {\n /** Export as the function's handler. */\n handler: (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ) => Promise<DurableExecutionInvocationOutput>;\n}\n\ninterface Invocation {\n lease?: Required<StoreLease>;\n}\n\n/** This function's ARN without its qualifier, from the ARN it was invoked under. */\nfunction unqualified(invokedFunctionArn: string): string {\n return invokedFunctionArn.split(':').slice(0, 7).join(':');\n}\n\nconst json = (body: unknown, status = 200) =>\n new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });\n\nexport function journeyLambda<Env extends JourneyLambdaEnv>(\n options: JourneyLambdaOptions<Env>\n): JourneyLambda {\n type HostEnv = Env & JourneyEnv;\n const {\n env: baseEnv,\n lambda: lambdaOption,\n startRetryMs,\n stopDrainMs,\n executionTimeoutMs,\n ...hostOptions\n } = options;\n const host = createJourneyHost<HostEnv>(\n { ...hostOptions, store: (env, database) => options.store(env, database) },\n // Node 24's fetch cannot reach APNs (HTTP/2 only): see http2-client.ts for when this can go\n { apnsFetch: http2Fetch() }\n );\n if (executionTimeoutMs !== undefined) {\n const tooLong = host\n .bundle()\n .workflows.filter((workflow) => longestJourneyMs(workflow) > executionTimeoutMs);\n if (tooLong.length > 0) {\n const days = (ms: number) => `${Math.ceil(ms / 86_400_000)} days`;\n throw new RangeError(\n `${tooLong.map((w) => `'${w.id}' can run ${days(longestJourneyMs(w))}`).join(', ')}, ` +\n `longer than the execution timeout (${days(executionTimeoutMs)}): raise ExecutionTimeout (up to 366 days)`\n );\n }\n }\n const lambda = lambdaOption ?? sdkLambdaApi();\n const ownHashes = new Set(host.bundle().workflows.map((workflow) => workflow.contentHash));\n const scope = new AsyncLocalStorage<Invocation>();\n\n /** The version this code was published as: `$LATEST` while developing. */\n const ownVersion = () => process.env.AWS_LAMBDA_FUNCTION_VERSION ?? '$LATEST';\n /** Numbered versions are ordered: the bundle compiled into a later one goes live by itself. */\n const bundleVersion = (): BundleVersion | undefined => {\n const version = ownVersion();\n return /^\\d+$/.test(version) ? { id: version, createdAt: Number(version) } : undefined;\n };\n\n /** The store and the env (with the instance port) of this invocation, opened on first use. */\n const open = (\n context: DurableContext\n ): { store: JourneyStore; env: HostEnv; executions: ExecutionRegistry } => {\n const invocation = scope.getStore();\n if (invocation === undefined) throw new Error('journeyLambda: outside an invocation');\n const placeholder = { ...baseEnv } as unknown as HostEnv;\n invocation.lease ??= host.lease(placeholder);\n const { store } = invocation.lease;\n const executions = store.executions;\n if (executions === undefined) {\n throw new Error(\n 'journeyLambda needs a store with an execution registry (PostgresJourneyStore)'\n );\n }\n const functionArn = unqualified(context.lambdaContext.invokedFunctionArn);\n const target = async (contentHash: string, version?: string): Promise<string> => {\n // An execution runs the code its IR was compiled with: the version whose bundle put the\n // route there, read in the same row as the hash — never a separately read \"live\" version,\n // which a release between the two reads would make another bundle's\n if (version !== undefined && /^\\d+$/.test(version)) return `${functionArn}:${version}`;\n // A forced deploy records no version: only this code's own workflows can run here\n if (ownHashes.has(contentHash)) return `${functionArn}:${ownVersion()}`;\n throw new Error(`no published version is known to carry workflow IR ${contentHash}`);\n };\n const instances = new AwsInstances({\n store,\n executions,\n lambda,\n target,\n ...(startRetryMs === undefined ? {} : { startRetryMs }),\n ...(stopDrainMs === undefined ? {} : { stopDrainMs }),\n });\n return { store, env: { ...baseEnv, JOURNEY: instances }, executions };\n };\n\n const run = async (input: RunInput, context: DurableContext): Promise<JourneyOutcome> => {\n const { store, env, executions } = open(context);\n const { instanceId, params } = input;\n // Only a start the router recorded may run, byte for byte: the function can be invoked by\n // anything its policy lets through, and an input of this shape would otherwise run any\n // workflow for any user. A stopped execution (an erasure) may not come back either. Outside\n // any step — a step would replay the first invocation's \"not stopped\" on every wake — so the\n // execution fails at once on a refusal; only the read itself is retried, briefly, when the\n // database stumbled.\n const record = await readWithRetry(() => executions.get(instanceId), {\n what: 'run entry',\n instanceId,\n });\n if (\n record === undefined ||\n record.stoppedAt !== undefined ||\n record.input !== JSON.stringify(input)\n ) {\n throw new PermanentError('journeyLambda: run input is not a recorded start');\n }\n // Running is the proof the start got through, whatever became of the invoke's response\n await context.step('$workflow:started', async () => {\n await executions.markSubmitted(\n instanceId,\n context.executionContext.durableExecutionArn,\n Date.now()\n );\n });\n return executeJourney(\n {\n instanceId,\n params,\n enteredAtMs: input.enteredAtMs,\n step: new AwsEngineStep(context, store, executions, lambda, params.userId, instanceId),\n },\n host.runParts(env, store, params)\n );\n };\n\n const http = (event: FunctionUrlEvent, context: DurableContext): Promise<FunctionUrlResult> => {\n const serve = async (): Promise<FunctionUrlResult> => {\n const method = event.requestContext.http.method;\n try {\n const { store, env } = open(context);\n const request = toRequest(event);\n // Only the bundle compiled into a version may route: one is never swapped in by hand\n if (new URL(request.url).pathname === '/deploy') {\n return await fromResponse(\n json(\n {\n error:\n 'deploy by publishing a version (POST /bundle/deploy applies its own bundle)',\n },\n 409\n ),\n method\n );\n }\n await host.applyOwnBundle(env, bundleVersion());\n return await fromResponse(await host.handle(request, env, store), method);\n } catch (error) {\n console.error('[workflow] request failed', error);\n return fromResponse(json({ error: 'internal error' }, 500), method);\n }\n };\n // A read (the bundle, a preview, a tracked link or pixel) answers straight from the handler:\n // its body can be large, and a step would checkpoint all of it. A write runs in one step,\n // never retried — the caller is waiting, and retries the request itself\n const method = event.requestContext.http.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return serve();\n return context.step('http', serve, { retryStrategy: () => ({ shouldRetry: false }) });\n };\n\n /**\n * The sweep as durable steps, one per page of users: an invocation cut short (the function's\n * timeout) is re-invoked, and the re-invocation replays the pages already checkpointed\n * instead of evaluating everyone again. No step retries on its own — a page that failed is\n * evaluated again by the next tick.\n */\n const sweep = async (context: DurableContext): Promise<SweepResult> => {\n const once = { retryStrategy: () => ({ shouldRetry: false }) };\n await context.step(\n 'sweep:maintain',\n async () => {\n const { store, env } = open(context);\n await host.applyOwnBundle(env, bundleVersion());\n await sweepMaintenance(env, store);\n },\n once\n );\n let users = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (let n = 0; ; n++) {\n const page = await context.step(\n `sweep:page:${n}`,\n async () => {\n const { store, env } = open(context);\n return sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE });\n },\n once\n );\n users += page.users;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = await context.step(\n 'sweep:snapshot',\n async () => {\n const { store } = open(context);\n return (await store.reporting?.snapshotSegments(Date.now())) ?? [];\n },\n once\n );\n return { users, started, sizes };\n };\n\n const durable = withDurableExecution(async (input: unknown, context: DurableContext) => {\n if (isFunctionUrlEvent(input)) return http(input, context);\n if (isRunInput(input)) return run(input, context);\n if (\n typeof input === 'object' &&\n input !== null &&\n (input as { type?: unknown }).type === SWEEP_EVENT_TYPE\n ) {\n return sweep(context);\n }\n throw new Error('journeyLambda: unrecognized event (not a Function URL request, run or sweep)');\n });\n\n const handler = async (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ): Promise<DurableExecutionInvocationOutput> => {\n const invocation: Invocation = {};\n try {\n // The durable wrapper reads the whole context Lambda passed, not just the fields above\n return await scope.run(invocation, () =>\n durable(event, context as Parameters<typeof durable>[1])\n );\n } finally {\n // Outside the durable wrapper: a suspended run returns PENDING without unwinding the\n // handler, so a `finally` in there would never release the connection\n await invocation.lease?.close().catch((error: unknown) => {\n console.error('[workflow] closing the store failed', error);\n });\n }\n };\n\n return { handler };\n}\n\nexport { AwsEngineStep, stepRetry } from './engine-step';\nexport { AwsInstances, RUN_EVENT_TYPE, executionName, isRunInput } from './instances';\nexport type { AwsInstancesOptions, RunInput } from './instances';\nexport { sdkLambdaApi } from './lambda-api';\nexport { bearerMatches, timingSafeEqual } from '../host/auth';\nexport type { Authorize } from '../host/auth';\nexport type { ExecutionState, LambdaApi } from './lambda-api';\nexport { fromResponse, isFunctionUrlEvent, toRequest } from './function-url';\nexport type { FunctionUrlEvent, FunctionUrlResult } from './function-url';\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["createJourneyHost","http2Fetch","longestJourneyMs","sdkLambdaApi","AsyncLocalStorage","AwsInstances","readWithRetry","PermanentError","executeJourney","AwsEngineStep","toRequest","fromResponse","sweepMaintenance","sweepSegmentsPaged","withDurableExecution","isFunctionUrlEvent","isRunInput"],"sources":["../../src/aws/index.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks';\nimport {\n type DurableContext,\n type DurableExecutionInvocationInput,\n type DurableExecutionInvocationOutput,\n withDurableExecution,\n} from '@aws/durable-execution-sdk-js';\nimport { longestJourneyMs } from '../engine/duration';\nimport type { JourneyOutcome } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport { executeJourney } from '../host/execute';\nimport { readWithRetry } from '../host/fresh-read';\nimport {\n SWEEP_PAGE_SIZE,\n type SweepResult,\n sweepMaintenance,\n sweepSegmentsPaged,\n} from '../host/ingest';\nimport { type JourneyHostOptions, createJourneyHost } from '../host/journey-host';\nimport type { JourneyEnv } from '../host/platform';\nimport { http2Fetch } from '../http2-client';\nimport type { BundleVersion, ExecutionRegistry, JourneyStore, StoreLease } from '../store/index';\nimport { AwsEngineStep } from './engine-step';\nimport {\n type FunctionUrlEvent,\n type FunctionUrlResult,\n fromResponse,\n isFunctionUrlEvent,\n toRequest,\n} from './function-url';\nimport { AwsInstances, type RunInput, isRunInput } from './instances';\nimport { type LambdaApi, sdkLambdaApi } from './lambda-api';\n\n/**\n * `@shware/workflow/aws` — the journey host as one Lambda durable function behind a Function\n * URL, on Postgres.\n *\n * The one handler tells three inputs apart by the event's top-level shape: a Function URL\n * request (the HTTP API — ingest, identify, devices, tracking, bundle, preview), an internal run\n * (one journey instance: one durable execution, started by an asynchronous invoke of this\n * function's own numbered version), and a scheduled maintenance tick. An HTTP body can never\n * pass for the other two: the Function URL always wraps it.\n *\n * const journeys = journeyLambda({\n * config,\n * env, // parsed from process.env (zod)\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database),\n * email: (env) => sesEmailTransport({ region: env.AWS_REGION }),\n * });\n * export const handler = journeys.handler;\n */\n\n/** The scheduled tick (EventBridge Scheduler's payload): the segment sweep and maintenance. */\nexport const SWEEP_EVENT_TYPE = '$workflow.sweep';\n\n/** What journeyLambda reads from its env: `JourneyEnv` without the binding it supplies itself. */\nexport type JourneyLambdaEnv = Omit<JourneyEnv, 'JOURNEY' | 'DB' | 'EMAIL'>;\n\nexport interface JourneyLambdaOptions<Env extends JourneyLambdaEnv> extends Omit<\n JourneyHostOptions<Env & JourneyEnv>,\n 'env' | 'store'\n> {\n /** The validated environment (parsed from `process.env` with zod by the app). */\n env: Env;\n /** The Postgres store: AWS needs its execution registry (`postgresJsStore`). */\n store: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /** The Lambda control plane; `@aws-sdk/client-lambda` with the execution role by default. */\n lambda?: LambdaApi;\n /** How long an unconfirmed start is retried (default 12 hours; keep it under the retention). */\n startRetryMs?: number;\n /**\n * How long an erasure waits after stopping an execution before it erases: an invocation\n * running at the stop goes on to its next checkpoint. Set it to at least the function's\n * timeout; 15 minutes (Lambda's maximum) by default.\n */\n stopDrainMs?: number;\n /**\n * The function's `DurableConfig.ExecutionTimeout`, in milliseconds. With it, a workflow that\n * can outlive it (`longestJourneyMs`) fails the handler's creation — a deploy's smoke test —\n * instead of being stopped by the platform midway and recorded as failed.\n */\n executionTimeoutMs?: number;\n}\n\n/**\n * The Lambda invocation context, as far as the handler's signature needs it — declared here so\n * the package's types do not depend on `@types/aws-lambda`. Lambda passes the full object.\n */\nexport interface LambdaContext {\n functionName: string;\n functionVersion: string;\n invokedFunctionArn: string;\n awsRequestId: string;\n getRemainingTimeInMillis(): number;\n}\n\nexport interface JourneyLambda {\n /** Export as the function's handler. */\n handler: (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ) => Promise<DurableExecutionInvocationOutput>;\n}\n\ninterface Invocation {\n lease?: Required<StoreLease>;\n}\n\n/** This function's ARN without its qualifier, from the ARN it was invoked under. */\nfunction unqualified(invokedFunctionArn: string): string {\n return invokedFunctionArn.split(':').slice(0, 7).join(':');\n}\n\nconst json = (body: unknown, status = 200) =>\n new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });\n\nexport function journeyLambda<Env extends JourneyLambdaEnv>(\n options: JourneyLambdaOptions<Env>\n): JourneyLambda {\n type HostEnv = Env & JourneyEnv;\n const {\n env: baseEnv,\n lambda: lambdaOption,\n startRetryMs,\n stopDrainMs,\n executionTimeoutMs,\n ...hostOptions\n } = options;\n const host = createJourneyHost<HostEnv>(\n { ...hostOptions, store: (env, database) => options.store(env, database) },\n // Node 24's fetch cannot reach APNs (HTTP/2 only): see http2-client.ts for when this can go\n { apnsFetch: http2Fetch() }\n );\n if (executionTimeoutMs !== undefined) {\n const tooLong = host\n .bundle()\n .workflows.filter((workflow) => longestJourneyMs(workflow) > executionTimeoutMs);\n if (tooLong.length > 0) {\n const days = (ms: number) => `${Math.ceil(ms / 86_400_000)} days`;\n throw new RangeError(\n `${tooLong.map((w) => `'${w.id}' can run ${days(longestJourneyMs(w))}`).join(', ')}, ` +\n `longer than the execution timeout (${days(executionTimeoutMs)}): raise ExecutionTimeout (up to 366 days)`\n );\n }\n }\n const lambda = lambdaOption ?? sdkLambdaApi();\n const ownHashes = new Set(host.bundle().workflows.map((workflow) => workflow.contentHash));\n const scope = new AsyncLocalStorage<Invocation>();\n\n /** The version this code was published as: `$LATEST` while developing. */\n const ownVersion = () => process.env.AWS_LAMBDA_FUNCTION_VERSION ?? '$LATEST';\n /** Numbered versions are ordered: the bundle compiled into a later one goes live by itself. */\n const bundleVersion = (): BundleVersion | undefined => {\n const version = ownVersion();\n return /^\\d+$/.test(version) ? { id: version, createdAt: Number(version) } : undefined;\n };\n\n /** The store and the env (with the instance port) of this invocation, opened on first use. */\n const open = (\n context: DurableContext\n ): { store: JourneyStore; env: HostEnv; executions: ExecutionRegistry } => {\n const invocation = scope.getStore();\n if (invocation === undefined) throw new Error('journeyLambda: outside an invocation');\n const placeholder = { ...baseEnv } as unknown as HostEnv;\n invocation.lease ??= host.lease(placeholder);\n const { store } = invocation.lease;\n const { executions } = store;\n const functionArn = unqualified(context.lambdaContext.invokedFunctionArn);\n const target = async (contentHash: string, version?: string): Promise<string> => {\n // An execution runs the code its IR was compiled with: the version whose bundle put the\n // route there, read in the same row as the hash — never a separately read \"live\" version,\n // which a release between the two reads would make another bundle's\n if (version !== undefined && /^\\d+$/.test(version)) return `${functionArn}:${version}`;\n // A forced deploy records no version: only this code's own workflows can run here\n if (ownHashes.has(contentHash)) return `${functionArn}:${ownVersion()}`;\n throw new Error(`no published version is known to carry workflow IR ${contentHash}`);\n };\n const instances = new AwsInstances({\n store,\n executions,\n lambda,\n target,\n ...(startRetryMs === undefined ? {} : { startRetryMs }),\n ...(stopDrainMs === undefined ? {} : { stopDrainMs }),\n });\n return { store, env: { ...baseEnv, JOURNEY: instances }, executions };\n };\n\n const run = async (input: RunInput, context: DurableContext): Promise<JourneyOutcome> => {\n const { store, env, executions } = open(context);\n const { instanceId, params } = input;\n // Only a start the router recorded may run, byte for byte: the function can be invoked by\n // anything its policy lets through, and an input of this shape would otherwise run any\n // workflow for any user. A stopped execution (an erasure) may not come back either. Outside\n // any step — a step would replay the first invocation's \"not stopped\" on every wake — so the\n // execution fails at once on a refusal; only the read itself is retried, briefly, when the\n // database stumbled.\n const record = await readWithRetry(() => executions.get(instanceId), {\n what: 'run entry',\n instanceId,\n });\n if (\n record === undefined ||\n record.stoppedAt !== undefined ||\n record.input !== JSON.stringify(input)\n ) {\n throw new PermanentError('journeyLambda: run input is not a recorded start');\n }\n // Running is the proof the start got through, whatever became of the invoke's response\n await context.step('$workflow:started', async () => {\n await executions.markSubmitted(\n instanceId,\n context.executionContext.durableExecutionArn,\n Date.now()\n );\n });\n return executeJourney(\n {\n instanceId,\n params,\n enteredAtMs: input.enteredAtMs,\n step: new AwsEngineStep(context, store, executions, lambda, params.userId, instanceId),\n },\n host.runParts(env, store, params)\n );\n };\n\n const http = (event: FunctionUrlEvent, context: DurableContext): Promise<FunctionUrlResult> => {\n const serve = async (): Promise<FunctionUrlResult> => {\n const method = event.requestContext.http.method;\n try {\n const { store, env } = open(context);\n const request = toRequest(event);\n // Only the bundle compiled into a version may route: one is never swapped in by hand\n if (new URL(request.url).pathname === '/deploy') {\n return await fromResponse(\n json(\n {\n error:\n 'deploy by publishing a version (POST /bundle/deploy applies its own bundle)',\n },\n 409\n ),\n method\n );\n }\n await host.applyOwnBundle(env, bundleVersion());\n return await fromResponse(await host.handle(request, env, store), method);\n } catch (error) {\n console.error('[workflow] request failed', error);\n return fromResponse(json({ error: 'internal error' }, 500), method);\n }\n };\n // A read (the bundle, a preview, a tracked link or pixel) answers straight from the handler:\n // its body can be large, and a step would checkpoint all of it. A write runs in one step,\n // never retried — the caller is waiting, and retries the request itself\n const method = event.requestContext.http.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return serve();\n return context.step('http', serve, { retryStrategy: () => ({ shouldRetry: false }) });\n };\n\n /**\n * The sweep as durable steps, one per page of users: an invocation cut short (the function's\n * timeout) is re-invoked, and the re-invocation replays the pages already checkpointed\n * instead of evaluating everyone again. No step retries on its own — a page that failed is\n * evaluated again by the next tick.\n */\n const sweep = async (context: DurableContext): Promise<SweepResult> => {\n const once = { retryStrategy: () => ({ shouldRetry: false }) };\n await context.step(\n 'sweep:maintain',\n async () => {\n const { store, env } = open(context);\n await host.applyOwnBundle(env, bundleVersion());\n await sweepMaintenance(env, store);\n },\n once\n );\n let users = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (let n = 0; ; n++) {\n const page = await context.step(\n `sweep:page:${n}`,\n async () => {\n const { store, env } = open(context);\n return sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE });\n },\n once\n );\n users += page.users;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = await context.step(\n 'sweep:snapshot',\n async () => {\n const { store } = open(context);\n return store.reporting.snapshotSegments(Date.now());\n },\n once\n );\n return { users, started, sizes };\n };\n\n const durable = withDurableExecution(async (input: unknown, context: DurableContext) => {\n if (isFunctionUrlEvent(input)) return http(input, context);\n if (isRunInput(input)) return run(input, context);\n if (\n typeof input === 'object' &&\n input !== null &&\n (input as { type?: unknown }).type === SWEEP_EVENT_TYPE\n ) {\n return sweep(context);\n }\n throw new Error('journeyLambda: unrecognized event (not a Function URL request, run or sweep)');\n });\n\n const handler = async (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ): Promise<DurableExecutionInvocationOutput> => {\n const invocation: Invocation = {};\n try {\n // The durable wrapper reads the whole context Lambda passed, not just the fields above\n return await scope.run(invocation, () =>\n durable(event, context as Parameters<typeof durable>[1])\n );\n } finally {\n // Outside the durable wrapper: a suspended run returns PENDING without unwinding the\n // handler, so a `finally` in there would never release the connection\n await invocation.lease?.close().catch((error: unknown) => {\n console.error('[workflow] closing the store failed', error);\n });\n }\n };\n\n return { handler };\n}\n\nexport { AwsEngineStep, stepRetry } from './engine-step';\nexport { AwsInstances, RUN_EVENT_TYPE, executionName, isRunInput } from './instances';\nexport type { AwsInstancesOptions, RunInput } from './instances';\nexport { sdkLambdaApi } from './lambda-api';\nexport { bearerMatches, timingSafeEqual } from '../host/auth';\nexport type { Authorize } from '../host/auth';\nexport type { ExecutionState, LambdaApi } from './lambda-api';\nexport { fromResponse, isFunctionUrlEvent, toRequest } from './function-url';\nexport type { FunctionUrlEvent, FunctionUrlResult } from './function-url';\n"],"mappings":"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"}
|