@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/dist/webpush.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webpush.cjs","names":["base64url","PermanentError","isRetryableStatus"],"sources":["../src/webpush.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { PermanentError, isRetryableStatus } from './engine/retry';\nimport { base64url } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/webpush` — browser push through the standard Web Push protocol: VAPID\n * (RFC 8292) identifies the sender, the payload is encrypted for the subscription (RFC 8291,\n * `aes128gcm`, RFC 8188). One sender reaches Chrome, Edge, Firefox and Safari 16+ — each\n * browser's push service accepts the same request, over HTTP/1.1 — with nothing but WebCrypto.\n *\n * The recipient is a browser's `PushSubscription` as JSON (`JSON.stringify(subscription)`),\n * registered like an app token (`registerDevice(userId, { token, platform: 'web' })`). The\n * service worker receives `{ title, body, image?, url?, template, …tracking data }` as the push\n * event's JSON.\n */\n\n/** A VAPID key pair: the uncompressed P-256 public key and the private scalar, base64url. */\nexport interface VapidKeys {\n publicKey: string;\n privateKey: string;\n}\n\nexport interface VapidSignerOptions extends VapidKeys {\n /** Who to contact about this sender: `mailto:` or an `https:` URL (RFC 8292). */\n subject: string;\n}\n\nexport interface WebPushOptions {\n /** The VAPID signer the requests are authorized with — shared by every sender on the same key pair. */\n credentials: VapidSigner;\n render: PushRenderer;\n /** Seconds the push service keeps an undelivered message; a day by default. */\n ttl?: number;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\n/**\n * How a push service's answer is classified (RFC 8030 §7 and §8.1, with the browsers' services\n * answering as documented there):\n *\n * - dead subscription: 404 and 410 — the browser unsubscribed or the subscription expired\n * (RFC 8030 §7.3: 410 Gone for an expired subscription) — and a subscription the sender\n * cannot parse (`InvalidSubscription`). The copy settles undelivered and the host forgets it.\n * - transient: 429 Too Many Requests (§8.4) and 5xx.\n * - everything else is this request or this sender: 400 (a bad header or body), 401 / 403 (the\n * VAPID key pair or subject, §8.2 of RFC 8292), 413 (the payload, RFC 8030 §7.2) and any other\n * 4xx are a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set([\n 'Gone',\n 'NotFound',\n 'InvalidSubscription',\n]);\n\ninterface Subscription {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n}\n\n/** A fresh VAPID key pair (once per project; the private key is a secret). */\nexport async function generateVapidKeys(): Promise<VapidKeys> {\n const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [\n 'sign',\n 'verify',\n ]);\n const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));\n const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);\n return { publicKey: base64url(raw), privateKey: jwk.d ?? '' };\n}\n\n/** The record size the header announces: RFC 8291 §4 fixes it at 4096 and one record per push. */\nconst RECORD_SIZE = 4096;\n/**\n * The largest plaintext one record carries. A push message body is at most 4096 octets (RFC 8030\n * §7.2); the aes128gcm header takes 86 of them (16 salt + 4 rs + 1 idlen + 65 key), the record its\n * 16-octet tag and the 1-octet delimiter — 3993 octets of plaintext (RFC 8291 §4). Anything past\n * that is either an invalid record or a 413 from the push service.\n */\nexport const MAX_PLAINTEXT_BYTES = 3993;\n/** Bytes WebCrypto accepts (backed by a plain ArrayBuffer). */\ntype Bytes = Uint8Array<ArrayBuffer>;\nconst encoder = new TextEncoder();\nconst utf8 = (text: string): Bytes => encoder.encode(text);\n\nexport function fromBase64url(text: string): Bytes {\n const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));\n return Uint8Array.from(binary, (char) => char.charCodeAt(0));\n}\n\nfunction concat(...parts: Uint8Array[]): Bytes {\n const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0));\n let offset = 0;\n for (const part of parts) {\n out.set(part, offset);\n offset += part.length;\n }\n return out;\n}\n\nasync function hmac(key: Bytes, data: Bytes): Promise<Bytes> {\n const imported = await crypto.subtle.importKey(\n 'raw',\n key,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign']\n );\n return new Uint8Array(await crypto.subtle.sign('HMAC', imported, data));\n}\n\n/** HKDF-SHA256 with a single output block (every length here is ≤ 32 bytes). */\nasync function hkdf(salt: Bytes, ikm: Bytes, info: Bytes, length: number): Promise<Bytes> {\n const prk = await hmac(salt, ikm);\n return (await hmac(prk, concat(info, new Uint8Array([1])))).slice(0, length);\n}\n\n/**\n * The `aes128gcm` body for one subscription (RFC 8291 §3.4): salt, record size, the sender's\n * ephemeral public key, then the payload sealed under a key both sides derive from ECDH and the\n * subscription's auth secret.\n */\nexport async function encryptPayload(\n payload: Bytes,\n p256dh: Bytes,\n authSecret: Bytes,\n salt: Bytes = crypto.getRandomValues(new Uint8Array(16))\n): Promise<Bytes> {\n const ephemeral = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [\n 'deriveBits',\n ]);\n const senderPublic = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey));\n const receiver = await crypto.subtle.importKey(\n 'raw',\n p256dh,\n { name: 'ECDH', namedCurve: 'P-256' },\n false,\n []\n );\n const shared = new Uint8Array(\n await crypto.subtle.deriveBits({ name: 'ECDH', public: receiver }, ephemeral.privateKey, 256)\n );\n const ikm = await hkdf(\n authSecret,\n shared,\n concat(utf8('WebPush: info\\0'), p256dh, senderPublic),\n 32\n );\n const cek = await hkdf(salt, ikm, utf8('Content-Encoding: aes128gcm\\0'), 16);\n const nonce = await hkdf(salt, ikm, utf8('Content-Encoding: nonce\\0'), 12);\n const key = await crypto.subtle.importKey('raw', cek, 'AES-GCM', false, ['encrypt']);\n // The single, last record: the payload and the 0x02 delimiter, no padding\n const sealed = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: nonce },\n key,\n concat(payload, new Uint8Array([2]))\n )\n );\n const header = new Uint8Array(16 + 4 + 1);\n header.set(salt, 0);\n new DataView(header.buffer).setUint32(16, RECORD_SIZE);\n header[20] = senderPublic.length;\n return concat(header, senderPublic, sealed);\n}\n\n/** How long a VAPID token is good for: RFC 8292 §2 allows at most 24 hours. */\nconst VAPID_EXP_S = 24 * 3600;\n/** How long a signed token is reused: half its life, so it is never sent near expiry. */\nconst VAPID_TTL_MS = 12 * 3600_000;\n\n/**\n * The VAPID `Authorization` header (RFC 8292 §3) for one key pair. The private key is imported\n * once; the JWT is signed per push service (its `aud` is the service's origin) and reused for\n * `VAPID_TTL_MS`. Concurrent callers for one origin share the one signing in flight, and a signing\n * that failed is not kept. One instance serves every sender built on the pair — a host keeps it\n * across runs, and builds the senders per run.\n */\nexport class VapidSigner {\n private key: Promise<CryptoKey> | undefined;\n private readonly signed = new Map<string, { value: string; issuedAt: number }>();\n private readonly signing = new Map<string, Promise<string>>();\n\n constructor(private readonly options: VapidSignerOptions) {}\n\n /** The header value for the push service at `audience` — its origin, or any URL on it. */\n async authorization(audience: string, now = Date.now()): Promise<string> {\n const origin = new URL(audience).origin;\n const current = this.signed.get(origin);\n if (current !== undefined && now - current.issuedAt < VAPID_TTL_MS) return current.value;\n let pending = this.signing.get(origin);\n if (pending === undefined) {\n pending = this.sign(origin, now)\n .then((value) => {\n this.signed.set(origin, { value, issuedAt: now });\n return value;\n })\n .finally(() => {\n this.signing.delete(origin);\n });\n this.signing.set(origin, pending);\n }\n return pending;\n }\n\n private privateKey(): Promise<CryptoKey> {\n if (this.key === undefined) {\n const publicKey = fromBase64url(this.options.publicKey);\n this.key = crypto.subtle\n .importKey(\n 'jwk',\n {\n kty: 'EC',\n crv: 'P-256',\n d: this.options.privateKey,\n x: base64url(publicKey.slice(1, 33)),\n y: base64url(publicKey.slice(33, 65)),\n ext: true,\n },\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n )\n .catch((error: unknown) => {\n this.key = undefined;\n throw error;\n });\n }\n return this.key;\n }\n\n private async sign(origin: string, now: number): Promise<string> {\n const key = await this.privateKey();\n const header = base64url(JSON.stringify({ typ: 'JWT', alg: 'ES256' }));\n const claims = base64url(\n JSON.stringify({\n aud: origin,\n exp: Math.floor(now / 1000) + VAPID_EXP_S,\n sub: this.options.subject,\n })\n );\n const signature = new Uint8Array(\n await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, utf8(`${header}.${claims}`))\n );\n return `vapid t=${header}.${claims}.${base64url(signature)}, k=${this.options.publicKey}`;\n }\n}\n\n/**\n * A browser subscription as a sender can use it: an https endpoint, a 65-byte uncompressed P-256\n * key and a 16-byte auth secret. Anything else — not JSON, missing keys, a key of the wrong size,\n * an endpoint that is not https — is undefined.\n */\nfunction parseSubscription(\n token: string\n): { endpoint: string; p256dh: Bytes; auth: Bytes } | undefined {\n try {\n const value = JSON.parse(token) as Partial<Subscription>;\n if (typeof value.endpoint !== 'string' || new URL(value.endpoint).protocol !== 'https:') {\n return undefined;\n }\n const p256dh = fromBase64url(value.keys?.p256dh ?? '');\n const auth = fromBase64url(value.keys?.auth ?? '');\n if (p256dh.length !== 65 || p256dh[0] !== 4 || auth.length !== 16) return undefined;\n return { endpoint: value.endpoint, p256dh, auth };\n } catch {\n return undefined;\n }\n}\n\nexport class WebPushSender implements MessageSender {\n constructor(private readonly options: WebPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`WebPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`WebPushSender: no subscription for user '${message.userId}'`);\n }\n const subscription = parseSubscription(message.recipient);\n if (subscription === undefined) {\n // Not a usable subscription: nothing will ever deliver to it, so do not retry into it\n return { delivered: false, reason: 'InvalidSubscription' };\n }\n const content = await this.options.render(message.template, message.props);\n const payload = utf8(\n JSON.stringify({\n ...content,\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...message.tracking?.data,\n })\n );\n if (payload.length > MAX_PLAINTEXT_BYTES) {\n // The same template renders the same size on every attempt: settle it before encrypting\n throw new PermanentError(\n `[webpush] payload for template '${message.template}' is ${payload.length} bytes; a push message carries at most ${MAX_PLAINTEXT_BYTES}`\n );\n }\n const body = await encryptPayload(payload, subscription.p256dh, subscription.auth);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(subscription.endpoint, {\n method: 'POST',\n headers: {\n authorization: await this.options.credentials.authorization(subscription.endpoint),\n 'content-encoding': 'aes128gcm',\n 'content-type': 'application/octet-stream',\n ttl: String(this.options.ttl ?? 86_400),\n urgency: 'normal',\n // A resend of the same copy replaces the first one while it is still queued (RFC 8030\n // §5.4), so an at-least-once retry does not show the notification twice\n topic: await webPushTopic(message.idempotencyKey),\n },\n body,\n });\n if (response.ok) return;\n const { status } = response;\n if (status === 404 || status === 410) {\n // The browser unsubscribed or the subscription expired: it will never deliver again\n console.warn(`[webpush] subscription gone for user ${message.userId}: ${status}`);\n return { delivered: false, reason: status === 410 ? 'Gone' : 'NotFound' };\n }\n const text = await response.text();\n if (isRetryableStatus(status)) throw new Error(`[webpush] ${status}: ${text}`);\n throw new PermanentError(`[webpush] push service refused the request: ${status} ${text}`);\n }\n}\n\n/**\n * The `Topic` header for one copy: RFC 8030 §5.4 allows at most 32 characters of the URL-safe\n * base64 alphabet, so the idempotency key goes through SHA-256 and is cut to size.\n */\nexport async function webPushTopic(idempotencyKey: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', utf8(idempotencyKey));\n return base64url(new Uint8Array(digest)).slice(0, 32);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"webpush.cjs","names":["base64url","Buffer","PermanentError","isRetryableStatus"],"sources":["../src/webpush.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { PermanentError, isRetryableStatus } from './engine/retry';\nimport { base64url } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/webpush` — browser push through the standard Web Push protocol: VAPID\n * (RFC 8292) identifies the sender, the payload is encrypted for the subscription (RFC 8291,\n * `aes128gcm`, RFC 8188). One sender reaches Chrome, Edge, Firefox and Safari 16+ — each\n * browser's push service accepts the same request, over HTTP/1.1 — with nothing but WebCrypto.\n *\n * The recipient is a browser's `PushSubscription` as JSON (`JSON.stringify(subscription)`),\n * registered like an app token (`registerDevice(userId, { token, platform: 'web' })`). The\n * service worker receives `{ title, body, image?, url?, template, …tracking data }` as the push\n * event's JSON.\n */\n\n/** A VAPID key pair: the uncompressed P-256 public key and the private scalar, base64url. */\nexport interface VapidKeys {\n publicKey: string;\n privateKey: string;\n}\n\nexport interface VapidSignerOptions extends VapidKeys {\n /** Who to contact about this sender: `mailto:` or an `https:` URL (RFC 8292). */\n subject: string;\n}\n\nexport interface WebPushOptions {\n /** The VAPID signer the requests are authorized with — shared by every sender on the same key pair. */\n credentials: VapidSigner;\n render: PushRenderer;\n /** Seconds the push service keeps an undelivered message; a day by default. */\n ttl?: number;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\n/**\n * How a push service's answer is classified (RFC 8030 §7 and §8.1, with the browsers' services\n * answering as documented there):\n *\n * - dead subscription: 404 and 410 — the browser unsubscribed or the subscription expired\n * (RFC 8030 §7.3: 410 Gone for an expired subscription) — and a subscription the sender\n * cannot parse (`InvalidSubscription`). The copy settles undelivered and the host forgets it.\n * - transient: 429 Too Many Requests (§8.4) and 5xx.\n * - everything else is this request or this sender: 400 (a bad header or body), 401 / 403 (the\n * VAPID key pair or subject, §8.2 of RFC 8292), 413 (the payload, RFC 8030 §7.2) and any other\n * 4xx are a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set([\n 'Gone',\n 'NotFound',\n 'InvalidSubscription',\n]);\n\ninterface Subscription {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n}\n\n/** A fresh VAPID key pair (once per project; the private key is a secret). */\nexport async function generateVapidKeys(): Promise<VapidKeys> {\n const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [\n 'sign',\n 'verify',\n ]);\n const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));\n const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);\n return { publicKey: base64url(raw), privateKey: jwk.d ?? '' };\n}\n\n/** The record size the header announces: RFC 8291 §4 fixes it at 4096 and one record per push. */\nconst RECORD_SIZE = 4096;\n/**\n * The largest plaintext one record carries. A push message body is at most 4096 octets (RFC 8030\n * §7.2); the aes128gcm header takes 86 of them (16 salt + 4 rs + 1 idlen + 65 key), the record its\n * 16-octet tag and the 1-octet delimiter — 3993 octets of plaintext (RFC 8291 §4). Anything past\n * that is either an invalid record or a 413 from the push service.\n */\nexport const MAX_PLAINTEXT_BYTES = 3993;\n/** Bytes WebCrypto accepts (backed by a plain ArrayBuffer). */\ntype Bytes = Uint8Array<ArrayBuffer>;\nconst encoder = new TextEncoder();\nconst utf8 = (text: string): Bytes => encoder.encode(text);\n\nexport function fromBase64url(text: string): Bytes {\n return new Uint8Array(Buffer.from(text, 'base64url'));\n}\n\nconst concat = (...parts: Uint8Array[]): Bytes => new Uint8Array(Buffer.concat(parts));\n\n/** HKDF-SHA256 (RFC 5869), as WebCrypto provides it. */\nasync function hkdf(salt: Bytes, ikm: Bytes, info: Bytes, length: number): Promise<Bytes> {\n const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);\n return new Uint8Array(\n await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, key, length * 8)\n );\n}\n\n/**\n * The `aes128gcm` body for one subscription (RFC 8291 §3.4): salt, record size, the sender's\n * ephemeral public key, then the payload sealed under a key both sides derive from ECDH and the\n * subscription's auth secret.\n */\nexport async function encryptPayload(\n payload: Bytes,\n p256dh: Bytes,\n authSecret: Bytes,\n salt: Bytes = crypto.getRandomValues(new Uint8Array(16))\n): Promise<Bytes> {\n const ephemeral = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [\n 'deriveBits',\n ]);\n const senderPublic = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey));\n const receiver = await crypto.subtle.importKey(\n 'raw',\n p256dh,\n { name: 'ECDH', namedCurve: 'P-256' },\n false,\n []\n );\n const shared = new Uint8Array(\n await crypto.subtle.deriveBits({ name: 'ECDH', public: receiver }, ephemeral.privateKey, 256)\n );\n const ikm = await hkdf(\n authSecret,\n shared,\n concat(utf8('WebPush: info\\0'), p256dh, senderPublic),\n 32\n );\n const cek = await hkdf(salt, ikm, utf8('Content-Encoding: aes128gcm\\0'), 16);\n const nonce = await hkdf(salt, ikm, utf8('Content-Encoding: nonce\\0'), 12);\n const key = await crypto.subtle.importKey('raw', cek, 'AES-GCM', false, ['encrypt']);\n // The single, last record: the payload and the 0x02 delimiter, no padding\n const sealed = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: nonce },\n key,\n concat(payload, new Uint8Array([2]))\n )\n );\n const header = new Uint8Array(16 + 4 + 1);\n header.set(salt, 0);\n new DataView(header.buffer).setUint32(16, RECORD_SIZE);\n header[20] = senderPublic.length;\n return concat(header, senderPublic, sealed);\n}\n\n/** How long a VAPID token is good for: RFC 8292 §2 allows at most 24 hours. */\nconst VAPID_EXP_S = 24 * 3600;\n/** How long a signed token is reused: half its life, so it is never sent near expiry. */\nconst VAPID_TTL_MS = 12 * 3600_000;\n\n/**\n * The VAPID `Authorization` header (RFC 8292 §3) for one key pair. The private key is imported\n * once; the JWT is signed per push service (its `aud` is the service's origin) and reused for\n * `VAPID_TTL_MS`. Concurrent callers for one origin share the one signing in flight, and a signing\n * that failed is not kept. One instance serves every sender built on the pair — a host keeps it\n * across runs, and builds the senders per run.\n */\nexport class VapidSigner {\n private key: Promise<CryptoKey> | undefined;\n private readonly signed = new Map<string, { value: string; issuedAt: number }>();\n private readonly signing = new Map<string, Promise<string>>();\n\n constructor(private readonly options: VapidSignerOptions) {}\n\n /** The header value for the push service at `audience` — its origin, or any URL on it. */\n async authorization(audience: string, now = Date.now()): Promise<string> {\n const origin = new URL(audience).origin;\n const current = this.signed.get(origin);\n if (current !== undefined && now - current.issuedAt < VAPID_TTL_MS) return current.value;\n let pending = this.signing.get(origin);\n if (pending === undefined) {\n pending = this.sign(origin, now)\n .then((value) => {\n this.signed.set(origin, { value, issuedAt: now });\n return value;\n })\n .finally(() => {\n this.signing.delete(origin);\n });\n this.signing.set(origin, pending);\n }\n return pending;\n }\n\n private privateKey(): Promise<CryptoKey> {\n if (this.key === undefined) {\n const publicKey = fromBase64url(this.options.publicKey);\n this.key = crypto.subtle\n .importKey(\n 'jwk',\n {\n kty: 'EC',\n crv: 'P-256',\n d: this.options.privateKey,\n x: base64url(publicKey.slice(1, 33)),\n y: base64url(publicKey.slice(33, 65)),\n ext: true,\n },\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n )\n .catch((error: unknown) => {\n this.key = undefined;\n throw error;\n });\n }\n return this.key;\n }\n\n private async sign(origin: string, now: number): Promise<string> {\n const key = await this.privateKey();\n const header = base64url(JSON.stringify({ typ: 'JWT', alg: 'ES256' }));\n const claims = base64url(\n JSON.stringify({\n aud: origin,\n exp: Math.floor(now / 1000) + VAPID_EXP_S,\n sub: this.options.subject,\n })\n );\n const signature = new Uint8Array(\n await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, utf8(`${header}.${claims}`))\n );\n return `vapid t=${header}.${claims}.${base64url(signature)}, k=${this.options.publicKey}`;\n }\n}\n\n/**\n * A browser subscription as a sender can use it: an https endpoint, a 65-byte uncompressed P-256\n * key and a 16-byte auth secret. Anything else — not JSON, missing keys, a key of the wrong size,\n * an endpoint that is not https — is undefined.\n */\nfunction parseSubscription(\n token: string\n): { endpoint: string; p256dh: Bytes; auth: Bytes } | undefined {\n try {\n const value = JSON.parse(token) as Partial<Subscription>;\n if (typeof value.endpoint !== 'string' || new URL(value.endpoint).protocol !== 'https:') {\n return undefined;\n }\n const p256dh = fromBase64url(value.keys?.p256dh ?? '');\n const auth = fromBase64url(value.keys?.auth ?? '');\n if (p256dh.length !== 65 || p256dh[0] !== 4 || auth.length !== 16) return undefined;\n return { endpoint: value.endpoint, p256dh, auth };\n } catch {\n return undefined;\n }\n}\n\nexport class WebPushSender implements MessageSender {\n constructor(private readonly options: WebPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`WebPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`WebPushSender: no subscription for user '${message.userId}'`);\n }\n const subscription = parseSubscription(message.recipient);\n if (subscription === undefined) {\n // Not a usable subscription: nothing will ever deliver to it, so do not retry into it\n return { delivered: false, reason: 'InvalidSubscription' };\n }\n const content = await this.options.render(message.template, message.props);\n const payload = utf8(\n JSON.stringify({\n ...content,\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...message.tracking?.data,\n })\n );\n if (payload.length > MAX_PLAINTEXT_BYTES) {\n // The same template renders the same size on every attempt: settle it before encrypting\n throw new PermanentError(\n `[webpush] payload for template '${message.template}' is ${payload.length} bytes; a push message carries at most ${MAX_PLAINTEXT_BYTES}`\n );\n }\n const body = await encryptPayload(payload, subscription.p256dh, subscription.auth);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(subscription.endpoint, {\n method: 'POST',\n headers: {\n authorization: await this.options.credentials.authorization(subscription.endpoint),\n 'content-encoding': 'aes128gcm',\n 'content-type': 'application/octet-stream',\n ttl: String(this.options.ttl ?? 86_400),\n urgency: 'normal',\n // A resend of the same copy replaces the first one while it is still queued (RFC 8030\n // §5.4), so an at-least-once retry does not show the notification twice\n topic: await webPushTopic(message.idempotencyKey),\n },\n body,\n });\n if (response.ok) return;\n const { status } = response;\n if (status === 404 || status === 410) {\n // The browser unsubscribed or the subscription expired: it will never deliver again\n console.warn(`[webpush] subscription gone for user ${message.userId}: ${status}`);\n return { delivered: false, reason: status === 410 ? 'Gone' : 'NotFound' };\n }\n const text = await response.text();\n if (isRetryableStatus(status)) throw new Error(`[webpush] ${status}: ${text}`);\n throw new PermanentError(`[webpush] push service refused the request: ${status} ${text}`);\n }\n}\n\n/**\n * The `Topic` header for one copy: RFC 8030 §5.4 allows at most 32 characters of the URL-safe\n * base64 alphabet, so the idempotency key goes through SHA-256 and is cut to size.\n */\nexport async function webPushTopic(idempotencyKey: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', utf8(idempotencyKey));\n return base64url(new Uint8Array(digest)).slice(0, 32);\n}\n"],"mappings":"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"}
|
package/dist/webpush.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webpush.d.cts","names":[],"sources":["../src/webpush.ts"],"mappings":";;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"webpush.d.cts","names":[],"sources":["../src/webpush.ts"],"mappings":";;;;;;;;;;;;;;;;iBAmBiB;EACf;EACA;;iBAGe,2BAA2B;;EAE1C;;iBAGe;;EAEf,aAAa;EACb,QAAQ;;EAER;;EAEA,eAAe;;;;;;;;;;;;;;qBAeJ,oBAAoB;;wBAYX,qBAAqB,QAAQ;;;;;;;qBAkBtC;;KAER,QAAQ,WAAW;wBAIR,cAAc,eAAe;;;;;;wBAmBvB,eACpB,SAAS,OACT,QAAQ,OACR,YAAY,OACZ,OAAM,QACL,QAAQ;;;;;;;;qBAmDE;mBAKkB;UAJrB;mBACS;mBACA;EAEjB,YAA6B,SAAS;;EAGhC,cAAc,kBAAkB,eAAmB;UAmBjD;UA0BM;;qBAuCH,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;;;;wBA4DjC,aAAa,yBAAyB"}
|
package/dist/webpush.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webpush.d.mts","names":[],"sources":["../src/webpush.ts"],"mappings":";;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"webpush.d.mts","names":[],"sources":["../src/webpush.ts"],"mappings":";;;;;;;;;;;;;;;;iBAmBiB;EACf;EACA;;iBAGe,2BAA2B;;EAE1C;;iBAGe;;EAEf,aAAa;EACb,QAAQ;;EAER;;EAEA,eAAe;;;;;;;;;;;;;;qBAeJ,oBAAoB;;wBAYX,qBAAqB,QAAQ;;;;;;;qBAkBtC;;KAER,QAAQ,WAAW;wBAIR,cAAc,eAAe;;;;;;wBAmBvB,eACpB,SAAS,OACT,QAAQ,OACR,YAAY,OACZ,OAAM,QACL,QAAQ;;;;;;;;qBAmDE;mBAKkB;UAJrB;mBACS;mBACA;EAEjB,YAA6B,SAAS;;EAGhC,cAAc,kBAAkB,eAAmB;UAmBjD;UA0BM;;qBAuCH,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;;;;wBA4DjC,aAAa,yBAAyB"}
|
package/dist/webpush.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PermanentError, isRetryableStatus } from "./engine/retry.mjs";
|
|
2
2
|
import { base64url } from "./jwt.mjs";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
3
4
|
//#region src/webpush.ts
|
|
4
5
|
/**
|
|
5
6
|
* How a push service's answer is classified (RFC 8030 §7 and §8.1, with the browsers' services
|
|
@@ -43,28 +44,18 @@ const MAX_PLAINTEXT_BYTES = 3993;
|
|
|
43
44
|
const encoder = new TextEncoder();
|
|
44
45
|
const utf8 = (text) => encoder.encode(text);
|
|
45
46
|
function fromBase64url(text) {
|
|
46
|
-
|
|
47
|
-
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
47
|
+
return new Uint8Array(Buffer.from(text, "base64url"));
|
|
48
48
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
let offset = 0;
|
|
52
|
-
for (const part of parts) {
|
|
53
|
-
out.set(part, offset);
|
|
54
|
-
offset += part.length;
|
|
55
|
-
}
|
|
56
|
-
return out;
|
|
57
|
-
}
|
|
58
|
-
async function hmac(key, data) {
|
|
59
|
-
const imported = await crypto.subtle.importKey("raw", key, {
|
|
60
|
-
name: "HMAC",
|
|
61
|
-
hash: "SHA-256"
|
|
62
|
-
}, false, ["sign"]);
|
|
63
|
-
return new Uint8Array(await crypto.subtle.sign("HMAC", imported, data));
|
|
64
|
-
}
|
|
65
|
-
/** HKDF-SHA256 with a single output block (every length here is ≤ 32 bytes). */
|
|
49
|
+
const concat = (...parts) => new Uint8Array(Buffer.concat(parts));
|
|
50
|
+
/** HKDF-SHA256 (RFC 5869), as WebCrypto provides it. */
|
|
66
51
|
async function hkdf(salt, ikm, info, length) {
|
|
67
|
-
|
|
52
|
+
const key = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
|
|
53
|
+
return new Uint8Array(await crypto.subtle.deriveBits({
|
|
54
|
+
name: "HKDF",
|
|
55
|
+
hash: "SHA-256",
|
|
56
|
+
salt,
|
|
57
|
+
info
|
|
58
|
+
}, key, length * 8));
|
|
68
59
|
}
|
|
69
60
|
/**
|
|
70
61
|
* The `aes128gcm` body for one subscription (RFC 8291 §3.4): salt, record size, the sender's
|
package/dist/webpush.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webpush.mjs","names":[],"sources":["../src/webpush.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { PermanentError, isRetryableStatus } from './engine/retry';\nimport { base64url } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/webpush` — browser push through the standard Web Push protocol: VAPID\n * (RFC 8292) identifies the sender, the payload is encrypted for the subscription (RFC 8291,\n * `aes128gcm`, RFC 8188). One sender reaches Chrome, Edge, Firefox and Safari 16+ — each\n * browser's push service accepts the same request, over HTTP/1.1 — with nothing but WebCrypto.\n *\n * The recipient is a browser's `PushSubscription` as JSON (`JSON.stringify(subscription)`),\n * registered like an app token (`registerDevice(userId, { token, platform: 'web' })`). The\n * service worker receives `{ title, body, image?, url?, template, …tracking data }` as the push\n * event's JSON.\n */\n\n/** A VAPID key pair: the uncompressed P-256 public key and the private scalar, base64url. */\nexport interface VapidKeys {\n publicKey: string;\n privateKey: string;\n}\n\nexport interface VapidSignerOptions extends VapidKeys {\n /** Who to contact about this sender: `mailto:` or an `https:` URL (RFC 8292). */\n subject: string;\n}\n\nexport interface WebPushOptions {\n /** The VAPID signer the requests are authorized with — shared by every sender on the same key pair. */\n credentials: VapidSigner;\n render: PushRenderer;\n /** Seconds the push service keeps an undelivered message; a day by default. */\n ttl?: number;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\n/**\n * How a push service's answer is classified (RFC 8030 §7 and §8.1, with the browsers' services\n * answering as documented there):\n *\n * - dead subscription: 404 and 410 — the browser unsubscribed or the subscription expired\n * (RFC 8030 §7.3: 410 Gone for an expired subscription) — and a subscription the sender\n * cannot parse (`InvalidSubscription`). The copy settles undelivered and the host forgets it.\n * - transient: 429 Too Many Requests (§8.4) and 5xx.\n * - everything else is this request or this sender: 400 (a bad header or body), 401 / 403 (the\n * VAPID key pair or subject, §8.2 of RFC 8292), 413 (the payload, RFC 8030 §7.2) and any other\n * 4xx are a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set([\n 'Gone',\n 'NotFound',\n 'InvalidSubscription',\n]);\n\ninterface Subscription {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n}\n\n/** A fresh VAPID key pair (once per project; the private key is a secret). */\nexport async function generateVapidKeys(): Promise<VapidKeys> {\n const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [\n 'sign',\n 'verify',\n ]);\n const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));\n const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);\n return { publicKey: base64url(raw), privateKey: jwk.d ?? '' };\n}\n\n/** The record size the header announces: RFC 8291 §4 fixes it at 4096 and one record per push. */\nconst RECORD_SIZE = 4096;\n/**\n * The largest plaintext one record carries. A push message body is at most 4096 octets (RFC 8030\n * §7.2); the aes128gcm header takes 86 of them (16 salt + 4 rs + 1 idlen + 65 key), the record its\n * 16-octet tag and the 1-octet delimiter — 3993 octets of plaintext (RFC 8291 §4). Anything past\n * that is either an invalid record or a 413 from the push service.\n */\nexport const MAX_PLAINTEXT_BYTES = 3993;\n/** Bytes WebCrypto accepts (backed by a plain ArrayBuffer). */\ntype Bytes = Uint8Array<ArrayBuffer>;\nconst encoder = new TextEncoder();\nconst utf8 = (text: string): Bytes => encoder.encode(text);\n\nexport function fromBase64url(text: string): Bytes {\n const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));\n return Uint8Array.from(binary, (char) => char.charCodeAt(0));\n}\n\nfunction concat(...parts: Uint8Array[]): Bytes {\n const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0));\n let offset = 0;\n for (const part of parts) {\n out.set(part, offset);\n offset += part.length;\n }\n return out;\n}\n\nasync function hmac(key: Bytes, data: Bytes): Promise<Bytes> {\n const imported = await crypto.subtle.importKey(\n 'raw',\n key,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign']\n );\n return new Uint8Array(await crypto.subtle.sign('HMAC', imported, data));\n}\n\n/** HKDF-SHA256 with a single output block (every length here is ≤ 32 bytes). */\nasync function hkdf(salt: Bytes, ikm: Bytes, info: Bytes, length: number): Promise<Bytes> {\n const prk = await hmac(salt, ikm);\n return (await hmac(prk, concat(info, new Uint8Array([1])))).slice(0, length);\n}\n\n/**\n * The `aes128gcm` body for one subscription (RFC 8291 §3.4): salt, record size, the sender's\n * ephemeral public key, then the payload sealed under a key both sides derive from ECDH and the\n * subscription's auth secret.\n */\nexport async function encryptPayload(\n payload: Bytes,\n p256dh: Bytes,\n authSecret: Bytes,\n salt: Bytes = crypto.getRandomValues(new Uint8Array(16))\n): Promise<Bytes> {\n const ephemeral = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [\n 'deriveBits',\n ]);\n const senderPublic = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey));\n const receiver = await crypto.subtle.importKey(\n 'raw',\n p256dh,\n { name: 'ECDH', namedCurve: 'P-256' },\n false,\n []\n );\n const shared = new Uint8Array(\n await crypto.subtle.deriveBits({ name: 'ECDH', public: receiver }, ephemeral.privateKey, 256)\n );\n const ikm = await hkdf(\n authSecret,\n shared,\n concat(utf8('WebPush: info\\0'), p256dh, senderPublic),\n 32\n );\n const cek = await hkdf(salt, ikm, utf8('Content-Encoding: aes128gcm\\0'), 16);\n const nonce = await hkdf(salt, ikm, utf8('Content-Encoding: nonce\\0'), 12);\n const key = await crypto.subtle.importKey('raw', cek, 'AES-GCM', false, ['encrypt']);\n // The single, last record: the payload and the 0x02 delimiter, no padding\n const sealed = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: nonce },\n key,\n concat(payload, new Uint8Array([2]))\n )\n );\n const header = new Uint8Array(16 + 4 + 1);\n header.set(salt, 0);\n new DataView(header.buffer).setUint32(16, RECORD_SIZE);\n header[20] = senderPublic.length;\n return concat(header, senderPublic, sealed);\n}\n\n/** How long a VAPID token is good for: RFC 8292 §2 allows at most 24 hours. */\nconst VAPID_EXP_S = 24 * 3600;\n/** How long a signed token is reused: half its life, so it is never sent near expiry. */\nconst VAPID_TTL_MS = 12 * 3600_000;\n\n/**\n * The VAPID `Authorization` header (RFC 8292 §3) for one key pair. The private key is imported\n * once; the JWT is signed per push service (its `aud` is the service's origin) and reused for\n * `VAPID_TTL_MS`. Concurrent callers for one origin share the one signing in flight, and a signing\n * that failed is not kept. One instance serves every sender built on the pair — a host keeps it\n * across runs, and builds the senders per run.\n */\nexport class VapidSigner {\n private key: Promise<CryptoKey> | undefined;\n private readonly signed = new Map<string, { value: string; issuedAt: number }>();\n private readonly signing = new Map<string, Promise<string>>();\n\n constructor(private readonly options: VapidSignerOptions) {}\n\n /** The header value for the push service at `audience` — its origin, or any URL on it. */\n async authorization(audience: string, now = Date.now()): Promise<string> {\n const origin = new URL(audience).origin;\n const current = this.signed.get(origin);\n if (current !== undefined && now - current.issuedAt < VAPID_TTL_MS) return current.value;\n let pending = this.signing.get(origin);\n if (pending === undefined) {\n pending = this.sign(origin, now)\n .then((value) => {\n this.signed.set(origin, { value, issuedAt: now });\n return value;\n })\n .finally(() => {\n this.signing.delete(origin);\n });\n this.signing.set(origin, pending);\n }\n return pending;\n }\n\n private privateKey(): Promise<CryptoKey> {\n if (this.key === undefined) {\n const publicKey = fromBase64url(this.options.publicKey);\n this.key = crypto.subtle\n .importKey(\n 'jwk',\n {\n kty: 'EC',\n crv: 'P-256',\n d: this.options.privateKey,\n x: base64url(publicKey.slice(1, 33)),\n y: base64url(publicKey.slice(33, 65)),\n ext: true,\n },\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n )\n .catch((error: unknown) => {\n this.key = undefined;\n throw error;\n });\n }\n return this.key;\n }\n\n private async sign(origin: string, now: number): Promise<string> {\n const key = await this.privateKey();\n const header = base64url(JSON.stringify({ typ: 'JWT', alg: 'ES256' }));\n const claims = base64url(\n JSON.stringify({\n aud: origin,\n exp: Math.floor(now / 1000) + VAPID_EXP_S,\n sub: this.options.subject,\n })\n );\n const signature = new Uint8Array(\n await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, utf8(`${header}.${claims}`))\n );\n return `vapid t=${header}.${claims}.${base64url(signature)}, k=${this.options.publicKey}`;\n }\n}\n\n/**\n * A browser subscription as a sender can use it: an https endpoint, a 65-byte uncompressed P-256\n * key and a 16-byte auth secret. Anything else — not JSON, missing keys, a key of the wrong size,\n * an endpoint that is not https — is undefined.\n */\nfunction parseSubscription(\n token: string\n): { endpoint: string; p256dh: Bytes; auth: Bytes } | undefined {\n try {\n const value = JSON.parse(token) as Partial<Subscription>;\n if (typeof value.endpoint !== 'string' || new URL(value.endpoint).protocol !== 'https:') {\n return undefined;\n }\n const p256dh = fromBase64url(value.keys?.p256dh ?? '');\n const auth = fromBase64url(value.keys?.auth ?? '');\n if (p256dh.length !== 65 || p256dh[0] !== 4 || auth.length !== 16) return undefined;\n return { endpoint: value.endpoint, p256dh, auth };\n } catch {\n return undefined;\n }\n}\n\nexport class WebPushSender implements MessageSender {\n constructor(private readonly options: WebPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`WebPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`WebPushSender: no subscription for user '${message.userId}'`);\n }\n const subscription = parseSubscription(message.recipient);\n if (subscription === undefined) {\n // Not a usable subscription: nothing will ever deliver to it, so do not retry into it\n return { delivered: false, reason: 'InvalidSubscription' };\n }\n const content = await this.options.render(message.template, message.props);\n const payload = utf8(\n JSON.stringify({\n ...content,\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...message.tracking?.data,\n })\n );\n if (payload.length > MAX_PLAINTEXT_BYTES) {\n // The same template renders the same size on every attempt: settle it before encrypting\n throw new PermanentError(\n `[webpush] payload for template '${message.template}' is ${payload.length} bytes; a push message carries at most ${MAX_PLAINTEXT_BYTES}`\n );\n }\n const body = await encryptPayload(payload, subscription.p256dh, subscription.auth);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(subscription.endpoint, {\n method: 'POST',\n headers: {\n authorization: await this.options.credentials.authorization(subscription.endpoint),\n 'content-encoding': 'aes128gcm',\n 'content-type': 'application/octet-stream',\n ttl: String(this.options.ttl ?? 86_400),\n urgency: 'normal',\n // A resend of the same copy replaces the first one while it is still queued (RFC 8030\n // §5.4), so an at-least-once retry does not show the notification twice\n topic: await webPushTopic(message.idempotencyKey),\n },\n body,\n });\n if (response.ok) return;\n const { status } = response;\n if (status === 404 || status === 410) {\n // The browser unsubscribed or the subscription expired: it will never deliver again\n console.warn(`[webpush] subscription gone for user ${message.userId}: ${status}`);\n return { delivered: false, reason: status === 410 ? 'Gone' : 'NotFound' };\n }\n const text = await response.text();\n if (isRetryableStatus(status)) throw new Error(`[webpush] ${status}: ${text}`);\n throw new PermanentError(`[webpush] push service refused the request: ${status} ${text}`);\n }\n}\n\n/**\n * The `Topic` header for one copy: RFC 8030 §5.4 allows at most 32 characters of the URL-safe\n * base64 alphabet, so the idempotency key goes through SHA-256 and is cut to size.\n */\nexport async function webPushTopic(idempotencyKey: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', utf8(idempotencyKey));\n return base64url(new Uint8Array(digest)).slice(0, 32);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"webpush.mjs","names":[],"sources":["../src/webpush.ts"],"sourcesContent":["import { Buffer } from 'node:buffer';\nimport type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { PermanentError, isRetryableStatus } from './engine/retry';\nimport { base64url } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/webpush` — browser push through the standard Web Push protocol: VAPID\n * (RFC 8292) identifies the sender, the payload is encrypted for the subscription (RFC 8291,\n * `aes128gcm`, RFC 8188). One sender reaches Chrome, Edge, Firefox and Safari 16+ — each\n * browser's push service accepts the same request, over HTTP/1.1 — with nothing but WebCrypto.\n *\n * The recipient is a browser's `PushSubscription` as JSON (`JSON.stringify(subscription)`),\n * registered like an app token (`registerDevice(userId, { token, platform: 'web' })`). The\n * service worker receives `{ title, body, image?, url?, template, …tracking data }` as the push\n * event's JSON.\n */\n\n/** A VAPID key pair: the uncompressed P-256 public key and the private scalar, base64url. */\nexport interface VapidKeys {\n publicKey: string;\n privateKey: string;\n}\n\nexport interface VapidSignerOptions extends VapidKeys {\n /** Who to contact about this sender: `mailto:` or an `https:` URL (RFC 8292). */\n subject: string;\n}\n\nexport interface WebPushOptions {\n /** The VAPID signer the requests are authorized with — shared by every sender on the same key pair. */\n credentials: VapidSigner;\n render: PushRenderer;\n /** Seconds the push service keeps an undelivered message; a day by default. */\n ttl?: number;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\n/**\n * How a push service's answer is classified (RFC 8030 §7 and §8.1, with the browsers' services\n * answering as documented there):\n *\n * - dead subscription: 404 and 410 — the browser unsubscribed or the subscription expired\n * (RFC 8030 §7.3: 410 Gone for an expired subscription) — and a subscription the sender\n * cannot parse (`InvalidSubscription`). The copy settles undelivered and the host forgets it.\n * - transient: 429 Too Many Requests (§8.4) and 5xx.\n * - everything else is this request or this sender: 400 (a bad header or body), 401 / 403 (the\n * VAPID key pair or subject, §8.2 of RFC 8292), 413 (the payload, RFC 8030 §7.2) and any other\n * 4xx are a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set([\n 'Gone',\n 'NotFound',\n 'InvalidSubscription',\n]);\n\ninterface Subscription {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n}\n\n/** A fresh VAPID key pair (once per project; the private key is a secret). */\nexport async function generateVapidKeys(): Promise<VapidKeys> {\n const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [\n 'sign',\n 'verify',\n ]);\n const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));\n const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);\n return { publicKey: base64url(raw), privateKey: jwk.d ?? '' };\n}\n\n/** The record size the header announces: RFC 8291 §4 fixes it at 4096 and one record per push. */\nconst RECORD_SIZE = 4096;\n/**\n * The largest plaintext one record carries. A push message body is at most 4096 octets (RFC 8030\n * §7.2); the aes128gcm header takes 86 of them (16 salt + 4 rs + 1 idlen + 65 key), the record its\n * 16-octet tag and the 1-octet delimiter — 3993 octets of plaintext (RFC 8291 §4). Anything past\n * that is either an invalid record or a 413 from the push service.\n */\nexport const MAX_PLAINTEXT_BYTES = 3993;\n/** Bytes WebCrypto accepts (backed by a plain ArrayBuffer). */\ntype Bytes = Uint8Array<ArrayBuffer>;\nconst encoder = new TextEncoder();\nconst utf8 = (text: string): Bytes => encoder.encode(text);\n\nexport function fromBase64url(text: string): Bytes {\n return new Uint8Array(Buffer.from(text, 'base64url'));\n}\n\nconst concat = (...parts: Uint8Array[]): Bytes => new Uint8Array(Buffer.concat(parts));\n\n/** HKDF-SHA256 (RFC 5869), as WebCrypto provides it. */\nasync function hkdf(salt: Bytes, ikm: Bytes, info: Bytes, length: number): Promise<Bytes> {\n const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);\n return new Uint8Array(\n await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, key, length * 8)\n );\n}\n\n/**\n * The `aes128gcm` body for one subscription (RFC 8291 §3.4): salt, record size, the sender's\n * ephemeral public key, then the payload sealed under a key both sides derive from ECDH and the\n * subscription's auth secret.\n */\nexport async function encryptPayload(\n payload: Bytes,\n p256dh: Bytes,\n authSecret: Bytes,\n salt: Bytes = crypto.getRandomValues(new Uint8Array(16))\n): Promise<Bytes> {\n const ephemeral = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [\n 'deriveBits',\n ]);\n const senderPublic = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey));\n const receiver = await crypto.subtle.importKey(\n 'raw',\n p256dh,\n { name: 'ECDH', namedCurve: 'P-256' },\n false,\n []\n );\n const shared = new Uint8Array(\n await crypto.subtle.deriveBits({ name: 'ECDH', public: receiver }, ephemeral.privateKey, 256)\n );\n const ikm = await hkdf(\n authSecret,\n shared,\n concat(utf8('WebPush: info\\0'), p256dh, senderPublic),\n 32\n );\n const cek = await hkdf(salt, ikm, utf8('Content-Encoding: aes128gcm\\0'), 16);\n const nonce = await hkdf(salt, ikm, utf8('Content-Encoding: nonce\\0'), 12);\n const key = await crypto.subtle.importKey('raw', cek, 'AES-GCM', false, ['encrypt']);\n // The single, last record: the payload and the 0x02 delimiter, no padding\n const sealed = new Uint8Array(\n await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv: nonce },\n key,\n concat(payload, new Uint8Array([2]))\n )\n );\n const header = new Uint8Array(16 + 4 + 1);\n header.set(salt, 0);\n new DataView(header.buffer).setUint32(16, RECORD_SIZE);\n header[20] = senderPublic.length;\n return concat(header, senderPublic, sealed);\n}\n\n/** How long a VAPID token is good for: RFC 8292 §2 allows at most 24 hours. */\nconst VAPID_EXP_S = 24 * 3600;\n/** How long a signed token is reused: half its life, so it is never sent near expiry. */\nconst VAPID_TTL_MS = 12 * 3600_000;\n\n/**\n * The VAPID `Authorization` header (RFC 8292 §3) for one key pair. The private key is imported\n * once; the JWT is signed per push service (its `aud` is the service's origin) and reused for\n * `VAPID_TTL_MS`. Concurrent callers for one origin share the one signing in flight, and a signing\n * that failed is not kept. One instance serves every sender built on the pair — a host keeps it\n * across runs, and builds the senders per run.\n */\nexport class VapidSigner {\n private key: Promise<CryptoKey> | undefined;\n private readonly signed = new Map<string, { value: string; issuedAt: number }>();\n private readonly signing = new Map<string, Promise<string>>();\n\n constructor(private readonly options: VapidSignerOptions) {}\n\n /** The header value for the push service at `audience` — its origin, or any URL on it. */\n async authorization(audience: string, now = Date.now()): Promise<string> {\n const origin = new URL(audience).origin;\n const current = this.signed.get(origin);\n if (current !== undefined && now - current.issuedAt < VAPID_TTL_MS) return current.value;\n let pending = this.signing.get(origin);\n if (pending === undefined) {\n pending = this.sign(origin, now)\n .then((value) => {\n this.signed.set(origin, { value, issuedAt: now });\n return value;\n })\n .finally(() => {\n this.signing.delete(origin);\n });\n this.signing.set(origin, pending);\n }\n return pending;\n }\n\n private privateKey(): Promise<CryptoKey> {\n if (this.key === undefined) {\n const publicKey = fromBase64url(this.options.publicKey);\n this.key = crypto.subtle\n .importKey(\n 'jwk',\n {\n kty: 'EC',\n crv: 'P-256',\n d: this.options.privateKey,\n x: base64url(publicKey.slice(1, 33)),\n y: base64url(publicKey.slice(33, 65)),\n ext: true,\n },\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n )\n .catch((error: unknown) => {\n this.key = undefined;\n throw error;\n });\n }\n return this.key;\n }\n\n private async sign(origin: string, now: number): Promise<string> {\n const key = await this.privateKey();\n const header = base64url(JSON.stringify({ typ: 'JWT', alg: 'ES256' }));\n const claims = base64url(\n JSON.stringify({\n aud: origin,\n exp: Math.floor(now / 1000) + VAPID_EXP_S,\n sub: this.options.subject,\n })\n );\n const signature = new Uint8Array(\n await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, utf8(`${header}.${claims}`))\n );\n return `vapid t=${header}.${claims}.${base64url(signature)}, k=${this.options.publicKey}`;\n }\n}\n\n/**\n * A browser subscription as a sender can use it: an https endpoint, a 65-byte uncompressed P-256\n * key and a 16-byte auth secret. Anything else — not JSON, missing keys, a key of the wrong size,\n * an endpoint that is not https — is undefined.\n */\nfunction parseSubscription(\n token: string\n): { endpoint: string; p256dh: Bytes; auth: Bytes } | undefined {\n try {\n const value = JSON.parse(token) as Partial<Subscription>;\n if (typeof value.endpoint !== 'string' || new URL(value.endpoint).protocol !== 'https:') {\n return undefined;\n }\n const p256dh = fromBase64url(value.keys?.p256dh ?? '');\n const auth = fromBase64url(value.keys?.auth ?? '');\n if (p256dh.length !== 65 || p256dh[0] !== 4 || auth.length !== 16) return undefined;\n return { endpoint: value.endpoint, p256dh, auth };\n } catch {\n return undefined;\n }\n}\n\nexport class WebPushSender implements MessageSender {\n constructor(private readonly options: WebPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`WebPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`WebPushSender: no subscription for user '${message.userId}'`);\n }\n const subscription = parseSubscription(message.recipient);\n if (subscription === undefined) {\n // Not a usable subscription: nothing will ever deliver to it, so do not retry into it\n return { delivered: false, reason: 'InvalidSubscription' };\n }\n const content = await this.options.render(message.template, message.props);\n const payload = utf8(\n JSON.stringify({\n ...content,\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...message.tracking?.data,\n })\n );\n if (payload.length > MAX_PLAINTEXT_BYTES) {\n // The same template renders the same size on every attempt: settle it before encrypting\n throw new PermanentError(\n `[webpush] payload for template '${message.template}' is ${payload.length} bytes; a push message carries at most ${MAX_PLAINTEXT_BYTES}`\n );\n }\n const body = await encryptPayload(payload, subscription.p256dh, subscription.auth);\n const doFetch = this.options.fetch ?? fetch;\n const response = await doFetch(subscription.endpoint, {\n method: 'POST',\n headers: {\n authorization: await this.options.credentials.authorization(subscription.endpoint),\n 'content-encoding': 'aes128gcm',\n 'content-type': 'application/octet-stream',\n ttl: String(this.options.ttl ?? 86_400),\n urgency: 'normal',\n // A resend of the same copy replaces the first one while it is still queued (RFC 8030\n // §5.4), so an at-least-once retry does not show the notification twice\n topic: await webPushTopic(message.idempotencyKey),\n },\n body,\n });\n if (response.ok) return;\n const { status } = response;\n if (status === 404 || status === 410) {\n // The browser unsubscribed or the subscription expired: it will never deliver again\n console.warn(`[webpush] subscription gone for user ${message.userId}: ${status}`);\n return { delivered: false, reason: status === 410 ? 'Gone' : 'NotFound' };\n }\n const text = await response.text();\n if (isRetryableStatus(status)) throw new Error(`[webpush] ${status}: ${text}`);\n throw new PermanentError(`[webpush] push service refused the request: ${status} ${text}`);\n }\n}\n\n/**\n * The `Topic` header for one copy: RFC 8030 §5.4 allows at most 32 characters of the URL-safe\n * base64 alphabet, so the idempotency key goes through SHA-256 and is cut to size.\n */\nexport async function webPushTopic(idempotencyKey: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', utf8(idempotencyKey));\n return base64url(new Uint8Array(digest)).slice(0, 32);\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shware/workflow",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/ShwareHQ/shware-sdk.git"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"dist",
|
|
10
|
-
"src/cloudflare/schema.sql",
|
|
11
10
|
"src/cloudflare/workers.d.ts"
|
|
12
11
|
],
|
|
13
12
|
"type": "module",
|
|
@@ -32,7 +31,6 @@
|
|
|
32
31
|
"default": "./dist/engine/index.cjs"
|
|
33
32
|
}
|
|
34
33
|
},
|
|
35
|
-
"./schema.sql": "./dist/cloudflare/schema.sql",
|
|
36
34
|
"./cloudflare": {
|
|
37
35
|
"import": {
|
|
38
36
|
"types": "./dist/cloudflare/index.d.mts",
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_store_d1 = require("../store/d1.cjs");
|
|
3
|
-
//#region src/cloudflare/d1-env.ts
|
|
4
|
-
/**
|
|
5
|
-
* The default store: D1 from the env's `DB` binding — tables and workflow bodies alike (see
|
|
6
|
-
* ../cloudflare/schema.sql). Every router function and the runner fall back to it when no store
|
|
7
|
-
* is passed, so a Worker that declares `DB` needs no wiring; one on another store (say
|
|
8
|
-
* PostgresJourneyStore) passes its own and may omit the binding entirely.
|
|
9
|
-
*/
|
|
10
|
-
function d1Store(env) {
|
|
11
|
-
if (env.DB === void 0) throw new Error("JourneyStore not configured: declare the DB binding for the default D1 store, or pass a store explicitly");
|
|
12
|
-
return new require_store_d1.D1JourneyStore(env.DB);
|
|
13
|
-
}
|
|
14
|
-
//#endregion
|
|
15
|
-
exports.d1Store = d1Store;
|
|
16
|
-
|
|
17
|
-
//# sourceMappingURL=d1-env.cjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"d1-env.cjs","names":["D1JourneyStore"],"sources":["../../src/cloudflare/d1-env.ts"],"sourcesContent":["import type { JourneyEnv } from '../host/platform';\nimport { D1JourneyStore } from '../store/d1';\nimport type { JourneyStore } from '../store/index';\n\n/**\n * The default store: D1 from the env's `DB` binding — tables and workflow bodies alike (see\n * ../cloudflare/schema.sql). Every router function and the runner fall back to it when no store\n * is passed, so a Worker that declares `DB` needs no wiring; one on another store (say\n * PostgresJourneyStore) passes its own and may omit the binding entirely.\n */\nexport function d1Store(env: JourneyEnv): JourneyStore {\n if (env.DB === undefined) {\n throw new Error(\n 'JourneyStore not configured: declare the DB binding for the default D1 store, or pass a store explicitly'\n );\n }\n return new D1JourneyStore(env.DB);\n}\n"],"mappings":";;;;;;;;;AAUA,SAAgB,QAAQ,KAA+B;CACrD,IAAI,IAAI,OAAO,KAAA,GACb,MAAM,IAAI,MACR,0GACF;CAEF,OAAO,IAAIA,iBAAAA,eAAe,IAAI,EAAE;AAClC"}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { JourneyEnv } from "../host/platform.cjs";
|
|
2
|
-
import { JourneyStore } from "../store/index.cjs";
|
|
3
|
-
//#region src/cloudflare/d1-env.d.ts
|
|
4
|
-
/**
|
|
5
|
-
* The default store: D1 from the env's `DB` binding — tables and workflow bodies alike (see
|
|
6
|
-
* ../cloudflare/schema.sql). Every router function and the runner fall back to it when no store
|
|
7
|
-
* is passed, so a Worker that declares `DB` needs no wiring; one on another store (say
|
|
8
|
-
* PostgresJourneyStore) passes its own and may omit the binding entirely.
|
|
9
|
-
*/
|
|
10
|
-
export declare function d1Store(env: JourneyEnv): JourneyStore;
|
|
11
|
-
//#endregion
|
|
12
|
-
//# sourceMappingURL=d1-env.d.cts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"d1-env.d.cts","names":[],"sources":["../../src/cloudflare/d1-env.ts"],"mappings":";;;;;;;;;wBAUgB,QAAQ,KAAK,aAAa"}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { JourneyEnv } from "../host/platform.mjs";
|
|
2
|
-
import { JourneyStore } from "../store/index.mjs";
|
|
3
|
-
//#region src/cloudflare/d1-env.d.ts
|
|
4
|
-
/**
|
|
5
|
-
* The default store: D1 from the env's `DB` binding — tables and workflow bodies alike (see
|
|
6
|
-
* ../cloudflare/schema.sql). Every router function and the runner fall back to it when no store
|
|
7
|
-
* is passed, so a Worker that declares `DB` needs no wiring; one on another store (say
|
|
8
|
-
* PostgresJourneyStore) passes its own and may omit the binding entirely.
|
|
9
|
-
*/
|
|
10
|
-
export declare function d1Store(env: JourneyEnv): JourneyStore;
|
|
11
|
-
//#endregion
|
|
12
|
-
//# sourceMappingURL=d1-env.d.mts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"d1-env.d.mts","names":[],"sources":["../../src/cloudflare/d1-env.ts"],"mappings":";;;;;;;;;wBAUgB,QAAQ,KAAK,aAAa"}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { D1JourneyStore } from "../store/d1.mjs";
|
|
2
|
-
//#region src/cloudflare/d1-env.ts
|
|
3
|
-
/**
|
|
4
|
-
* The default store: D1 from the env's `DB` binding — tables and workflow bodies alike (see
|
|
5
|
-
* ../cloudflare/schema.sql). Every router function and the runner fall back to it when no store
|
|
6
|
-
* is passed, so a Worker that declares `DB` needs no wiring; one on another store (say
|
|
7
|
-
* PostgresJourneyStore) passes its own and may omit the binding entirely.
|
|
8
|
-
*/
|
|
9
|
-
function d1Store(env) {
|
|
10
|
-
if (env.DB === void 0) throw new Error("JourneyStore not configured: declare the DB binding for the default D1 store, or pass a store explicitly");
|
|
11
|
-
return new D1JourneyStore(env.DB);
|
|
12
|
-
}
|
|
13
|
-
//#endregion
|
|
14
|
-
export { d1Store };
|
|
15
|
-
|
|
16
|
-
//# sourceMappingURL=d1-env.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"d1-env.mjs","names":[],"sources":["../../src/cloudflare/d1-env.ts"],"sourcesContent":["import type { JourneyEnv } from '../host/platform';\nimport { D1JourneyStore } from '../store/d1';\nimport type { JourneyStore } from '../store/index';\n\n/**\n * The default store: D1 from the env's `DB` binding — tables and workflow bodies alike (see\n * ../cloudflare/schema.sql). Every router function and the runner fall back to it when no store\n * is passed, so a Worker that declares `DB` needs no wiring; one on another store (say\n * PostgresJourneyStore) passes its own and may omit the binding entirely.\n */\nexport function d1Store(env: JourneyEnv): JourneyStore {\n if (env.DB === undefined) {\n throw new Error(\n 'JourneyStore not configured: declare the DB binding for the default D1 store, or pass a store explicitly'\n );\n }\n return new D1JourneyStore(env.DB);\n}\n"],"mappings":";;;;;;;;AAUA,SAAgB,QAAQ,KAA+B;CACrD,IAAI,IAAI,OAAO,KAAA,GACb,MAAM,IAAI,MACR,0GACF;CAEF,OAAO,IAAI,eAAe,IAAI,EAAE;AAClC"}
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
-- Journey engine data plane (D1). Conditions are evaluated by querying at
|
|
2
|
-
-- decision time: lazy, with no real-time segment materialisation.
|
|
3
|
-
|
|
4
|
-
-- Raw event log. Every count- and window-based condition reads this table, so a
|
|
5
|
-
-- duplicated row silently changes a journey's behaviour, and `id` is what stops
|
|
6
|
-
-- that: it is the occurrence's identity, chosen by whoever sent it, and the key
|
|
7
|
-
-- constraint makes a second delivery of the same occurrence a no-op. Ingest is
|
|
8
|
-
-- reachable from two retrying callers (a client retrying a 500, and the
|
|
9
|
-
-- interpreter's send_event step being retried after it already committed its
|
|
10
|
-
-- row), so both pass an id they can reproduce.
|
|
11
|
-
--
|
|
12
|
-
-- The id is per user: the key is (user_id, id), so one sender's id can never
|
|
13
|
-
-- block another user's event, and a producer only has to keep its ids unique
|
|
14
|
-
-- within a user.
|
|
15
|
-
--
|
|
16
|
-
-- Only the sender knows whether two deliveries describe one occurrence or two,
|
|
17
|
-
-- so the engine never invents that answer: an ingest that arrives without an id
|
|
18
|
-
-- is given a fresh one, which is a truthful "this is its own occurrence" rather
|
|
19
|
-
-- than a guess.
|
|
20
|
-
CREATE TABLE IF NOT EXISTS events (
|
|
21
|
-
id TEXT NOT NULL,
|
|
22
|
-
user_id TEXT NOT NULL,
|
|
23
|
-
name TEXT NOT NULL,
|
|
24
|
-
ts INTEGER NOT NULL,
|
|
25
|
-
payload TEXT NOT NULL DEFAULT '{}',
|
|
26
|
-
PRIMARY KEY (user_id, id)
|
|
27
|
-
);
|
|
28
|
-
CREATE INDEX IF NOT EXISTS idx_events_user_name_ts ON events (user_id, name, ts);
|
|
29
|
-
|
|
30
|
-
CREATE TABLE IF NOT EXISTS profiles (
|
|
31
|
-
user_id TEXT PRIMARY KEY,
|
|
32
|
-
props TEXT NOT NULL DEFAULT '{}'
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
-- Workflow bodies (WorkflowIR JSON), content-addressed. They live beside the
|
|
36
|
-
-- routes that point at them so a deploy is read-your-writes: the trigger row
|
|
37
|
-
-- and the body it names are in one strongly consistent database, and a route
|
|
38
|
-
-- never names a body another store has yet to catch up on.
|
|
39
|
-
CREATE TABLE IF NOT EXISTS workflow_ir (
|
|
40
|
-
content_hash TEXT PRIMARY KEY,
|
|
41
|
-
ir TEXT NOT NULL
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
-- Deploy artefacts: segment definitions (resolved by id during evaluation)
|
|
45
|
-
-- and the trigger routing table.
|
|
46
|
-
-- Push devices: one row per provider token, owned by the user who registered it last.
|
|
47
|
-
-- user_id is null for a device whose user signed out: it stays registered until the next
|
|
48
|
-
-- sign-in binds it again. A database created while it was NOT NULL needs the table rebuilt.
|
|
49
|
-
CREATE TABLE IF NOT EXISTS devices (
|
|
50
|
-
token TEXT PRIMARY KEY,
|
|
51
|
-
user_id TEXT,
|
|
52
|
-
platform TEXT NOT NULL,
|
|
53
|
-
ts INTEGER NOT NULL
|
|
54
|
-
);
|
|
55
|
-
CREATE INDEX IF NOT EXISTS idx_devices_user_ts ON devices (user_id, ts);
|
|
56
|
-
|
|
57
|
-
CREATE TABLE IF NOT EXISTS segments (
|
|
58
|
-
id TEXT PRIMARY KEY,
|
|
59
|
-
condition TEXT NOT NULL,
|
|
60
|
-
hash TEXT NOT NULL
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
-- Event trigger routes. `where` gates on the triggering event's payload
|
|
64
|
-
-- (a payload-only condition tree); `filter` gates on the profile.
|
|
65
|
-
CREATE TABLE IF NOT EXISTS triggers (
|
|
66
|
-
workflow TEXT PRIMARY KEY,
|
|
67
|
-
hash TEXT NOT NULL,
|
|
68
|
-
event TEXT NOT NULL,
|
|
69
|
-
"where" TEXT,
|
|
70
|
-
filter TEXT
|
|
71
|
-
);
|
|
72
|
-
CREATE INDEX IF NOT EXISTS idx_triggers_event ON triggers (event);
|
|
73
|
-
|
|
74
|
-
-- Segment-entry trigger routes.
|
|
75
|
-
CREATE TABLE IF NOT EXISTS segment_triggers (
|
|
76
|
-
workflow TEXT PRIMARY KEY,
|
|
77
|
-
hash TEXT NOT NULL,
|
|
78
|
-
segment TEXT NOT NULL
|
|
79
|
-
);
|
|
80
|
-
CREATE INDEX IF NOT EXISTS idx_segment_triggers_segment ON segment_triggers (segment);
|
|
81
|
-
|
|
82
|
-
-- Lazily maintained segment membership, covering only segments that something
|
|
83
|
-
-- triggers on. Transitions are re-evaluated when the user is ingested or
|
|
84
|
-
-- identified, and by the scheduled sweep (journeyWorker's `scheduled`), which
|
|
85
|
-
-- also catches drift driven purely by the clock.
|
|
86
|
-
CREATE TABLE IF NOT EXISTS segment_members (
|
|
87
|
-
segment TEXT NOT NULL,
|
|
88
|
-
user_id TEXT NOT NULL,
|
|
89
|
-
ts INTEGER NOT NULL,
|
|
90
|
-
PRIMARY KEY (segment, user_id)
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
-- Workflows a segment entry still has to start for a member: written with the
|
|
94
|
-
-- membership, removed as each starts, so a failed start is retried by the next
|
|
95
|
-
-- evaluation instead of being hidden behind the membership.
|
|
96
|
-
CREATE TABLE IF NOT EXISTS segment_starts (
|
|
97
|
-
segment TEXT NOT NULL,
|
|
98
|
-
workflow TEXT NOT NULL,
|
|
99
|
-
user_id TEXT NOT NULL,
|
|
100
|
-
ts INTEGER NOT NULL,
|
|
101
|
-
PRIMARY KEY (segment, workflow, user_id)
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
-- Erasure tombstones (forgetUser): every write about a user is conditional on
|
|
105
|
-
-- no row here. While erased_at is null the user's data stays until every
|
|
106
|
-
-- running instance is stopped, and the scheduled sweep retries what is left;
|
|
107
|
-
-- once set, the data is gone and the row stays so the id is never written again.
|
|
108
|
-
CREATE TABLE IF NOT EXISTS erasures (
|
|
109
|
-
user_id TEXT PRIMARY KEY,
|
|
110
|
-
ts INTEGER NOT NULL,
|
|
111
|
-
erased_at INTEGER
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
-- Entry ledger: the once-per-user policy, instance addressing, and the audit
|
|
115
|
-
-- trail of which IR version each user was pinned to.
|
|
116
|
-
--
|
|
117
|
-
-- The primary key is what makes the policy atomic, and it is also the whole
|
|
118
|
-
-- policy: a user who has entered cannot enter again. The one exception is a
|
|
119
|
-
-- row left at 'failed', which records an instance that died rather than a
|
|
120
|
-
-- journey the user received; the router takes such a row over so the entry can
|
|
121
|
-
-- be retried (see startJourney). A general re-entry policy is a separate
|
|
122
|
-
-- design question and deliberately not encoded here.
|
|
123
|
-
CREATE TABLE IF NOT EXISTS entries (
|
|
124
|
-
workflow TEXT NOT NULL,
|
|
125
|
-
user_id TEXT NOT NULL,
|
|
126
|
-
instance_id TEXT NOT NULL,
|
|
127
|
-
hash TEXT NOT NULL,
|
|
128
|
-
status TEXT NOT NULL,
|
|
129
|
-
ts INTEGER NOT NULL,
|
|
130
|
-
-- When the runner admitted itself (admitInstance), before its first
|
|
131
|
-
-- observable effect; null until then, so an erasure knows nothing of the
|
|
132
|
-
-- instance has run yet.
|
|
133
|
-
admitted_at INTEGER,
|
|
134
|
-
PRIMARY KEY (workflow, user_id)
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
-- wait_until wake routing. `wake_handle` is an opaque handle (the instance id
|
|
138
|
-
-- on Cloudflare, a callback id on AWS). The primary key makes the interpreter's
|
|
139
|
-
-- re-subscription on every wait attempt idempotent, which its one-shot-callback
|
|
140
|
-
-- contract requires.
|
|
141
|
-
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
142
|
-
user_id TEXT NOT NULL,
|
|
143
|
-
event TEXT NOT NULL,
|
|
144
|
-
wake_handle TEXT NOT NULL,
|
|
145
|
-
ts INTEGER NOT NULL,
|
|
146
|
-
PRIMARY KEY (wake_handle, event)
|
|
147
|
-
);
|
|
148
|
-
CREATE INDEX IF NOT EXISTS idx_subs_user_event ON subscriptions (user_id, event);
|