@shware/workflow 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +4 -2
  2. package/dist/aws/engine-step.cjs +2 -3
  3. package/dist/aws/engine-step.cjs.map +1 -1
  4. package/dist/aws/engine-step.d.cts +10 -0
  5. package/dist/aws/engine-step.d.cts.map +1 -1
  6. package/dist/aws/engine-step.d.mts +10 -0
  7. package/dist/aws/engine-step.d.mts.map +1 -1
  8. package/dist/aws/engine-step.mjs +2 -3
  9. package/dist/aws/engine-step.mjs.map +1 -1
  10. package/dist/aws/index.cjs +9 -1
  11. package/dist/aws/index.cjs.map +1 -1
  12. package/dist/aws/index.d.cts +6 -0
  13. package/dist/aws/index.d.cts.map +1 -1
  14. package/dist/aws/index.d.mts +6 -0
  15. package/dist/aws/index.d.mts.map +1 -1
  16. package/dist/aws/index.mjs +9 -1
  17. package/dist/aws/index.mjs.map +1 -1
  18. package/dist/cloudflare/runner.cjs +4 -5
  19. package/dist/cloudflare/runner.cjs.map +1 -1
  20. package/dist/cloudflare/runner.d.cts.map +1 -1
  21. package/dist/cloudflare/runner.d.mts.map +1 -1
  22. package/dist/cloudflare/runner.mjs +4 -5
  23. package/dist/cloudflare/runner.mjs.map +1 -1
  24. package/dist/engine/duration.cjs +34 -0
  25. package/dist/engine/duration.cjs.map +1 -0
  26. package/dist/engine/duration.d.cts +5 -0
  27. package/dist/engine/duration.d.cts.map +1 -0
  28. package/dist/engine/duration.d.mts +5 -0
  29. package/dist/engine/duration.d.mts.map +1 -0
  30. package/dist/engine/duration.mjs +33 -0
  31. package/dist/engine/duration.mjs.map +1 -0
  32. package/dist/engine/index.cjs +5 -0
  33. package/dist/engine/index.d.cts +3 -1
  34. package/dist/engine/index.d.mts +3 -1
  35. package/dist/engine/index.mjs +3 -1
  36. package/dist/engine/retry.cjs +29 -0
  37. package/dist/engine/retry.cjs.map +1 -0
  38. package/dist/engine/retry.d.cts +17 -0
  39. package/dist/engine/retry.d.cts.map +1 -0
  40. package/dist/engine/retry.d.mts +17 -0
  41. package/dist/engine/retry.d.mts.map +1 -0
  42. package/dist/engine/retry.mjs +27 -0
  43. package/dist/engine/retry.mjs.map +1 -0
  44. package/dist/host/journey-host.cjs +15 -0
  45. package/dist/host/journey-host.cjs.map +1 -1
  46. package/dist/host/journey-host.d.cts.map +1 -1
  47. package/dist/host/journey-host.d.mts.map +1 -1
  48. package/dist/host/journey-host.mjs +15 -0
  49. package/dist/host/journey-host.mjs.map +1 -1
  50. package/dist/transports/cloudflare-email.cjs +4 -0
  51. package/dist/transports/cloudflare-email.cjs.map +1 -1
  52. package/dist/transports/cloudflare-email.d.cts +2 -0
  53. package/dist/transports/cloudflare-email.d.cts.map +1 -1
  54. package/dist/transports/cloudflare-email.d.mts +2 -0
  55. package/dist/transports/cloudflare-email.d.mts.map +1 -1
  56. package/dist/transports/cloudflare-email.mjs +4 -0
  57. package/dist/transports/cloudflare-email.mjs.map +1 -1
  58. package/dist/transports/ses.cjs +4 -0
  59. package/dist/transports/ses.cjs.map +1 -1
  60. package/dist/transports/ses.d.cts +2 -0
  61. package/dist/transports/ses.d.cts.map +1 -1
  62. package/dist/transports/ses.d.mts +2 -0
  63. package/dist/transports/ses.d.mts.map +1 -1
  64. package/dist/transports/ses.mjs +4 -0
  65. package/dist/transports/ses.mjs.map +1 -1
  66. package/dist/transports/sigv4.cjs +6 -1
  67. package/dist/transports/sigv4.cjs.map +1 -1
  68. package/dist/transports/sigv4.d.cts.map +1 -1
  69. package/dist/transports/sigv4.d.mts.map +1 -1
  70. package/dist/transports/sigv4.mjs +6 -1
  71. package/dist/transports/sigv4.mjs.map +1 -1
  72. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"journey-host.mjs","names":[],"sources":["../../src/host/journey-host.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { JourneyEnv, JourneyParams } from '../cloudflare/bindings';\nimport { d1Store } from '../cloudflare/d1-env';\nimport { type JourneyConfig, compileConfig } from '../config';\nimport { ContractValidationError, checkEvent } from '../contract';\nimport { assertEmailConfig } from '../email';\nimport type { EmailTransport } from '../email-sender';\nimport { RegistryActionInvoker } from '../engine/actions';\nimport type { JourneyObserver, MessageSender } from '../engine/ports';\nimport { scaleSegmentWindows } from '../engine/time-scale';\nimport type { BundleIR, ScalarIR } from '../ir';\nimport { registryRenderer } from '../react-email';\nimport { reportingObserver } from '../reporting/observer';\nimport { handleTracking } from '../reporting/tracking';\nimport type { JourneyRuntimeOptions } from '../runtime';\nimport {\n type BundleVersion,\n type JourneyStore,\n SchemaMismatchError,\n type StoreLease,\n} from '../store/index';\nimport { DEFAULT_SCHEMA } from '../store/tables';\nimport { TemplateValidationError, indexTemplates, registryValidator } from '../templates';\nimport { channelsFromConfig, runtimeFromConfig } from './channels';\nimport type { JourneyRunParts } from './execute';\nimport {\n type SweepResult,\n deployBundle,\n handleRequest,\n ingestEvent,\n sweepSegments,\n} from './router';\n\n/**\n * What a journey host is, apart from the runtime it runs on: the validated environment, the\n * store lease, the outlets a run is wired to, the HTTP API (tracking, sweep, bundle, preview,\n * ingest) and the bundle that goes live with the code. `journeyWorker` (Cloudflare) and\n * `journeyLambda` (AWS) are this plus how their platform starts, wakes and stops instances.\n */\n\nexport interface JourneyHostOptions<Env extends JourneyEnv> {\n /**\n * The project's `workflow.config.ts` (`defineConfig`): what the host\n * deploys (`GET /bundle`, `POST /bundle/deploy` serve `compileConfig`), the\n * template registries the senders, the validator and `/preview` render from,\n * the contract every `/events` and `/identify` is checked against, the email\n * defaults, actions, and the runtime / APNs / FCM settings. Secrets come\n * from the env under the names `JourneyEnv` documents.\n */\n config: JourneyConfig;\n /**\n * The data plane, opened per request and per workflow run. Return a\n * `StoreLease` when the client must be released afterwards (a Postgres\n * connection); defaults to D1 + KV from the env's bindings. `database` is\n * the config's (`schema` defaulting to `workflow`), for a Postgres store:\n *\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database)\n */\n store?: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /**\n * Where journey emails go: a transport built from the validated env —\n * `sesEmailTransport(...)`, `cloudflareEmailTransport(...)`, or your own. Which one is\n * independent of where the workflow runs. Without it the email channel fails its sends,\n * saying so.\n */\n email?: (env: Env) => EmailTransport;\n /**\n * Reporting hooks: node arrivals, every copy sent (with the tracking it should carry — click\n * links and an open pixel for email, payload data for a push), and how each instance ended.\n * By default a store that keeps reporting (Postgres) records them itself, and the host\n * serves the email tracking routes (`/t/...`, on `WORKFLOW_ORIGIN`); override to report\n * elsewhere.\n */\n observer?: (env: Env, store: JourneyStore) => JourneyObserver;\n /**\n * Message outlet override (tests, a custom gateway): replaces every channel, `email`\n * included. By default the channels the config describes.\n */\n messages?: (env: Env, store: JourneyStore) => MessageSender;\n /** Per-instance runtime options override (a tester in fast-forward); by default `config.runtime` for `ENVIRONMENT`. */\n runtime?: (env: Env, params: JourneyParams) => JourneyRuntimeOptions;\n /**\n * The host's environment, as a zod schema over its vars and secrets (bindings pass through\n * untouched): checked the first time the host runs, so a missing or malformed variable fails\n * there — every problem listed — not at the send that needs it. Webhook templates read their\n * URL from the parsed values: `webhook: (env: Env) => env.WEBHOOK_DISCORD_ALERTS`.\n */\n env?: core.$ZodType<object>;\n /** 'enforce' (default) answers an event or patch the contract refuses with a 400; 'report' only logs it. */\n contractMode?: 'enforce' | 'report';\n}\n\n/** The host's environment failed its schema (`env` option). */\nexport class EnvValidationError extends Error {\n override readonly name = 'EnvValidationError';\n constructor(readonly issues: readonly string[]) {\n super(`the host's environment is invalid:\\n ${issues.join('\\n ')}`);\n }\n}\n\nexport interface JourneyHost<Env extends JourneyEnv> {\n config: JourneyConfig;\n /** The env, parsed by the schema once per env object (throws EnvValidationError). */\n checkEnv(env: Env): Env;\n /** A store for one request or run, its segment windows scaled with the runtime. */\n lease(env: Env): Required<StoreLease>;\n /** The bundle compiled into this code. */\n bundle(): BundleIR;\n /** The outlets one run is wired to (the store is the run's). */\n runParts(env: Env, store: JourneyStore, params: JourneyParams): JourneyRunParts;\n /** The HTTP API over one store: tracking, sweep, bundle, preview, then the ingest router. */\n handle(request: Request, env: Env, store: JourneyStore): Promise<Response>;\n /** The scheduled sweep (`sweepSegments`) on a store of its own. */\n sweep(env: Env): Promise<SweepResult>;\n /**\n * Make the bundle compiled into `version` live, once per process, only over an older live\n * version (the store records it). Undefined when there is nothing to do; a failure is logged\n * and retried a minute later.\n */\n applyOwnBundle(env: Env, version: BundleVersion | undefined): Promise<void> | undefined;\n}\n\n/** What the platform adapter itself contributes. */\nexport interface JourneyPlatform {\n /** An HTTP/2 fetch for APNs, where the runtime's own fetch is HTTP/1.1 (Node). */\n apnsFetch?: typeof fetch;\n}\n\nexport function createJourneyHost<Env extends JourneyEnv>(\n options: JourneyHostOptions<Env>,\n platform: JourneyPlatform = {}\n): JourneyHost<Env> {\n const { config } = options;\n const emailIds = indexTemplates('email', config.templates?.email);\n // A config still on `emails` (its sender would be silently dropped) fails here, not at a send\n assertEmailConfig(config, emailIds.values());\n // Parsed once per env object: the runtime hands the same one to every request of an isolate\n const checked = new WeakMap<object, Env>();\n const checkEnv = (env: Env): Env => {\n if (options.env === undefined) return env;\n const cached = checked.get(env);\n if (cached !== undefined) return cached;\n const result = z.safeParse(options.env, env);\n if (!result.success) {\n // Names and reasons only: the values are secrets\n throw new EnvValidationError(\n result.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)\n );\n }\n const merged: Env = { ...env, ...result.data };\n checked.set(env, merged);\n return merged;\n };\n let compiled: BundleIR | undefined;\n const bundle = () => (compiled ??= compileConfig(config));\n const emails = config.templates?.email;\n const database = { schema: config.database?.schema ?? DEFAULT_SCHEMA };\n // In fast-forward, segment windows scale with the journeys reading them (time-scale.ts)\n const lease = (env: Env): Required<StoreLease> => {\n const opened = options.store === undefined ? d1Store(env) : options.store(env, database);\n const noop = async () => {};\n const factor = runtimeFromConfig(config, env).timeScale;\n return 'store' in opened\n ? { store: scaleSegmentWindows(opened.store, factor), close: opened.close ?? noop }\n : { store: scaleSegmentWindows(opened, factor), close: noop };\n };\n\n const messages = (env: Env, store: JourneyStore): MessageSender => {\n if (options.messages !== undefined) return options.messages(env, store);\n return channelsFromConfig(config, env, {\n store,\n email: options.email?.(env),\n apnsFetch: platform.apnsFetch,\n });\n };\n\n const observer = (env: Env, store: JourneyStore): JourneyObserver | undefined => {\n if (options.observer !== undefined) return options.observer(env, store);\n return store.reporting === undefined\n ? undefined\n : reportingObserver(store.reporting, env.WORKFLOW_ORIGIN);\n };\n\n const runParts = (env: Env, store: JourneyStore, params: JourneyParams): JourneyRunParts => ({\n store,\n env,\n runtime:\n options.runtime === undefined ? runtimeFromConfig(config, env) : options.runtime(env, params),\n messages: messages(env, store),\n // An action ref is a registered action structurally: the same object carries name, code and hash\n actions: new RegistryActionInvoker(config.actions ?? []),\n contract: config.contract,\n validator: registryValidator(config.templates ?? {}),\n observer: observer(env, store),\n });\n\n const json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n });\n\n const authorized = (request: Request, env: Env): boolean =>\n env.API_TOKEN === undefined ||\n request.headers.get('authorization') === `Bearer ${env.API_TOKEN}`;\n\n const preview = emails === undefined ? undefined : registryRenderer(emails, config.email);\n\n const handle = async (request: Request, env: Env, store: JourneyStore): Promise<Response> => {\n const url = new URL(request.url);\n // Opened by recipients, so no bearer: the routes only record, and redirect to recorded URLs\n if (url.pathname.startsWith('/t/') && store.reporting !== undefined) {\n const openedEvent = config.webPush?.openedEvent;\n const tracked = await handleTracking(request, store.reporting, {\n legacyUnsubscribeUrl: config.email?.unsubscribeUrl,\n product: config.title,\n quickMs: config.email?.tracking?.quickClickMs,\n pushOpened:\n openedEvent === undefined\n ? undefined\n : async ({ messageId, userId, at }) => {\n const payload: Record<string, unknown> = { message_id: messageId };\n const contract = config.contract;\n const verdict =\n contract === undefined ? undefined : checkEvent(contract, openedEvent, payload);\n if (verdict !== undefined && !verdict.ok) {\n throw new ContractValidationError(openedEvent, verdict.issues);\n }\n // Keyed by the copy: a second tap of the same notification is the same event\n await ingestEvent(\n env,\n {\n id: `${openedEvent}:${messageId}`,\n userId,\n event: openedEvent,\n payload: verdict?.data ?? payload,\n ts: at,\n },\n store\n );\n },\n });\n if (tracked !== undefined) return tracked;\n }\n if (url.pathname === '/segments/sweep') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'POST') return json({ error: 'method not allowed' }, 405);\n return json(await sweepSegments(env, store));\n }\n if (url.pathname === '/bundle') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'GET') return json({ error: 'method not allowed' }, 405);\n return json(bundle());\n }\n if (url.pathname === '/bundle/deploy') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'POST') return json({ error: 'method not allowed' }, 405);\n try {\n return json(await deployBundle(env, bundle(), store));\n } catch (error) {\n if (!(error instanceof SchemaMismatchError)) throw error;\n return json({ error: error.message, problems: error.problems }, 503);\n }\n }\n if (emails !== undefined && preview !== undefined && url.pathname.startsWith('/preview/')) {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'GET') return json({ error: 'method not allowed' }, 405);\n const key = url.pathname.slice('/preview/'.length);\n if (!emailIds.has(key)) return json({ error: `unknown email template: ${key}` }, 404);\n // `?props=<JSON object>`: JSON keeps numbers and booleans typed, which query strings cannot\n let props: unknown = {};\n try {\n props = JSON.parse(url.searchParams.get('props') ?? '{}');\n } catch {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n if (typeof props !== 'object' || props === null || Array.isArray(props)) {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n try {\n const { subject, html } = await preview(key, props as Record<string, ScalarIR>);\n return new Response(html, {\n headers: { 'content-type': 'text/html; charset=utf-8', 'x-subject': subject },\n });\n } catch (error) {\n // The query string is the props: a refusal is the caller's to fix, with the field paths\n if (error instanceof TemplateValidationError) {\n return json({ error: error.message, issues: error.issues }, 400);\n }\n throw error;\n }\n }\n return handleRequest(request, env, store, {\n contract: config.contract,\n mode: options.contractMode,\n });\n };\n\n const sweep = async (env: Env) => {\n const { store, close } = lease(env);\n try {\n return await sweepSegments(env, store);\n } finally {\n await close();\n }\n };\n\n // The bundle compiled into this version goes live on its own, once per process: the first\n // request or scheduled run reads which version's bundle is live (one row) and applies this one\n // only over an older version, so one still serving during a rollout can never put its routes\n // back. A failure (say the migrations have not run) is logged and retried a minute later. Only\n // the request that starts the check gets its promise back: a request context cannot wait on\n // another's I/O.\n let applying: Promise<void> | undefined;\n let retryAt = 0;\n const applyOwnBundle = (env: Env, version: BundleVersion | undefined) => {\n if (version === undefined || version.id === '' || Number.isNaN(version.createdAt)) {\n return undefined;\n }\n if (applying !== undefined || Date.now() < retryAt) return undefined;\n applying = (async () => {\n const { store, close } = lease(env);\n try {\n if (store.liveBundleVersion === undefined) return; // D1: POST /bundle/deploy\n const live = await store.liveBundleVersion();\n if (live !== undefined && (live.id === version.id || live.createdAt >= version.createdAt))\n return;\n const result = await deployBundle(env, bundle(), store, version);\n if (result.applied) console.log(`[workflow] version ${version.id} is live`);\n } finally {\n await close();\n }\n })().catch((error: unknown) => {\n applying = undefined;\n retryAt = Date.now() + 60_000;\n console.error(`[workflow] could not make version ${version.id} live`, error);\n });\n return applying;\n };\n\n return { config, checkEnv, lease, bundle, runParts, handle, sweep, applyOwnBundle };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"journey-host.mjs","names":[],"sources":["../../src/host/journey-host.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { JourneyEnv, JourneyParams } from '../cloudflare/bindings';\nimport { d1Store } from '../cloudflare/d1-env';\nimport { type JourneyConfig, compileConfig } from '../config';\nimport { ContractValidationError, checkEvent } from '../contract';\nimport { assertEmailConfig } from '../email';\nimport type { EmailTransport } from '../email-sender';\nimport { RegistryActionInvoker } from '../engine/actions';\nimport type { JourneyObserver, MessageSender } from '../engine/ports';\nimport { scaleSegmentWindows } from '../engine/time-scale';\nimport type { BundleIR, NodeIR, ScalarIR } from '../ir';\nimport { registryRenderer } from '../react-email';\nimport { reportingObserver } from '../reporting/observer';\nimport { handleTracking } from '../reporting/tracking';\nimport type { JourneyRuntimeOptions } from '../runtime';\nimport {\n type BundleVersion,\n type JourneyStore,\n SchemaMismatchError,\n type StoreLease,\n} from '../store/index';\nimport { DEFAULT_SCHEMA } from '../store/tables';\nimport { TemplateValidationError, indexTemplates, registryValidator } from '../templates';\nimport { channelsFromConfig, runtimeFromConfig } from './channels';\nimport type { JourneyRunParts } from './execute';\nimport {\n type SweepResult,\n deployBundle,\n handleRequest,\n ingestEvent,\n sweepSegments,\n} from './router';\n\n/**\n * What a journey host is, apart from the runtime it runs on: the validated environment, the\n * store lease, the outlets a run is wired to, the HTTP API (tracking, sweep, bundle, preview,\n * ingest) and the bundle that goes live with the code. `journeyWorker` (Cloudflare) and\n * `journeyLambda` (AWS) are this plus how their platform starts, wakes and stops instances.\n */\n\nexport interface JourneyHostOptions<Env extends JourneyEnv> {\n /**\n * The project's `workflow.config.ts` (`defineConfig`): what the host\n * deploys (`GET /bundle`, `POST /bundle/deploy` serve `compileConfig`), the\n * template registries the senders, the validator and `/preview` render from,\n * the contract every `/events` and `/identify` is checked against, the email\n * defaults, actions, and the runtime / APNs / FCM settings. Secrets come\n * from the env under the names `JourneyEnv` documents.\n */\n config: JourneyConfig;\n /**\n * The data plane, opened per request and per workflow run. Return a\n * `StoreLease` when the client must be released afterwards (a Postgres\n * connection); defaults to D1 + KV from the env's bindings. `database` is\n * the config's (`schema` defaulting to `workflow`), for a Postgres store:\n *\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database)\n */\n store?: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /**\n * Where journey emails go: a transport built from the validated env —\n * `sesEmailTransport(...)`, `cloudflareEmailTransport(...)`, or your own. Which one is\n * independent of where the workflow runs. Without it the email channel fails its sends,\n * saying so.\n */\n email?: (env: Env) => EmailTransport;\n /**\n * Reporting hooks: node arrivals, every copy sent (with the tracking it should carry — click\n * links and an open pixel for email, payload data for a push), and how each instance ended.\n * By default a store that keeps reporting (Postgres) records them itself, and the host\n * serves the email tracking routes (`/t/...`, on `WORKFLOW_ORIGIN`); override to report\n * elsewhere.\n */\n observer?: (env: Env, store: JourneyStore) => JourneyObserver;\n /**\n * Message outlet override (tests, a custom gateway): replaces every channel, `email`\n * included. By default the channels the config describes.\n */\n messages?: (env: Env, store: JourneyStore) => MessageSender;\n /** Per-instance runtime options override (a tester in fast-forward); by default `config.runtime` for `ENVIRONMENT`. */\n runtime?: (env: Env, params: JourneyParams) => JourneyRuntimeOptions;\n /**\n * The host's environment, as a zod schema over its vars and secrets (bindings pass through\n * untouched): checked the first time the host runs, so a missing or malformed variable fails\n * there — every problem listed — not at the send that needs it. Webhook templates read their\n * URL from the parsed values: `webhook: (env: Env) => env.WEBHOOK_DISCORD_ALERTS`.\n */\n env?: core.$ZodType<object>;\n /** 'enforce' (default) answers an event or patch the contract refuses with a 400; 'report' only logs it. */\n contractMode?: 'enforce' | 'report';\n}\n\n/** The host's environment failed its schema (`env` option). */\nexport class EnvValidationError extends Error {\n override readonly name = 'EnvValidationError';\n constructor(readonly issues: readonly string[]) {\n super(`the host's environment is invalid:\\n ${issues.join('\\n ')}`);\n }\n}\n\nexport interface JourneyHost<Env extends JourneyEnv> {\n config: JourneyConfig;\n /** The env, parsed by the schema once per env object (throws EnvValidationError). */\n checkEnv(env: Env): Env;\n /** A store for one request or run, its segment windows scaled with the runtime. */\n lease(env: Env): Required<StoreLease>;\n /** The bundle compiled into this code. */\n bundle(): BundleIR;\n /** The outlets one run is wired to (the store is the run's). */\n runParts(env: Env, store: JourneyStore, params: JourneyParams): JourneyRunParts;\n /** The HTTP API over one store: tracking, sweep, bundle, preview, then the ingest router. */\n handle(request: Request, env: Env, store: JourneyStore): Promise<Response>;\n /** The scheduled sweep (`sweepSegments`) on a store of its own. */\n sweep(env: Env): Promise<SweepResult>;\n /**\n * Make the bundle compiled into `version` live, once per process, only over an older live\n * version (the store records it). Undefined when there is nothing to do; a failure is logged\n * and retried a minute later.\n */\n applyOwnBundle(env: Env, version: BundleVersion | undefined): Promise<void> | undefined;\n}\n\n/** What the platform adapter itself contributes. */\nexport interface JourneyPlatform {\n /** An HTTP/2 fetch for APNs, where the runtime's own fetch is HTTP/1.1 (Node). */\n apnsFetch?: typeof fetch;\n}\n\nfunction sendsEmail(nodes: readonly NodeIR[]): boolean {\n return nodes.some((node) => {\n switch (node.type) {\n case 'message':\n return node.channel === 'email';\n case 'wait_until':\n return Array.isArray(node.onTimeout) && sendsEmail(node.onTimeout);\n case 'branch':\n return (\n node.cases.some((branch) => sendsEmail(branch.flow)) || sendsEmail(node.otherwise ?? [])\n );\n case 'cohort':\n return node.arms.some((arm) => sendsEmail(arm.flow));\n default:\n return false;\n }\n });\n}\n\nexport function createJourneyHost<Env extends JourneyEnv>(\n options: JourneyHostOptions<Env>,\n platform: JourneyPlatform = {}\n): JourneyHost<Env> {\n const { config } = options;\n const emailIds = indexTemplates('email', config.templates?.email);\n // A config still on `emails` (its sender would be silently dropped) fails here, not at a send\n assertEmailConfig(config, emailIds.values());\n // A journey that emails with nowhere to send fails here too, not at its first email\n if (options.email === undefined && options.messages === undefined) {\n const emailing = compileConfig(config).workflows.filter((workflow) =>\n sendsEmail(workflow.flow)\n );\n if (emailing.length > 0) {\n throw new TypeError(\n `workflows ${emailing.map((w) => `'${w.id}'`).join(', ')} send email, but the host has no ` +\n '`email` transport: pass `email: (env) => cloudflareEmailTransport(...)` or `sesEmailTransport(...)`'\n );\n }\n }\n // Parsed once per env object: the runtime hands the same one to every request of an isolate\n const checked = new WeakMap<object, Env>();\n const checkEnv = (env: Env): Env => {\n if (options.env === undefined) return env;\n const cached = checked.get(env);\n if (cached !== undefined) return cached;\n const result = z.safeParse(options.env, env);\n if (!result.success) {\n // Names and reasons only: the values are secrets\n throw new EnvValidationError(\n result.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)\n );\n }\n const merged: Env = { ...env, ...result.data };\n checked.set(env, merged);\n return merged;\n };\n let compiled: BundleIR | undefined;\n const bundle = () => (compiled ??= compileConfig(config));\n const emails = config.templates?.email;\n const database = { schema: config.database?.schema ?? DEFAULT_SCHEMA };\n // In fast-forward, segment windows scale with the journeys reading them (time-scale.ts)\n const lease = (env: Env): Required<StoreLease> => {\n const opened = options.store === undefined ? d1Store(env) : options.store(env, database);\n const noop = async () => {};\n const factor = runtimeFromConfig(config, env).timeScale;\n return 'store' in opened\n ? { store: scaleSegmentWindows(opened.store, factor), close: opened.close ?? noop }\n : { store: scaleSegmentWindows(opened, factor), close: noop };\n };\n\n const messages = (env: Env, store: JourneyStore): MessageSender => {\n if (options.messages !== undefined) return options.messages(env, store);\n return channelsFromConfig(config, env, {\n store,\n email: options.email?.(env),\n apnsFetch: platform.apnsFetch,\n });\n };\n\n const observer = (env: Env, store: JourneyStore): JourneyObserver | undefined => {\n if (options.observer !== undefined) return options.observer(env, store);\n return store.reporting === undefined\n ? undefined\n : reportingObserver(store.reporting, env.WORKFLOW_ORIGIN);\n };\n\n const runParts = (env: Env, store: JourneyStore, params: JourneyParams): JourneyRunParts => ({\n store,\n env,\n runtime:\n options.runtime === undefined ? runtimeFromConfig(config, env) : options.runtime(env, params),\n messages: messages(env, store),\n // An action ref is a registered action structurally: the same object carries name, code and hash\n actions: new RegistryActionInvoker(config.actions ?? []),\n contract: config.contract,\n validator: registryValidator(config.templates ?? {}),\n observer: observer(env, store),\n });\n\n const json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n });\n\n const authorized = (request: Request, env: Env): boolean =>\n env.API_TOKEN === undefined ||\n request.headers.get('authorization') === `Bearer ${env.API_TOKEN}`;\n\n const preview = emails === undefined ? undefined : registryRenderer(emails, config.email);\n\n const handle = async (request: Request, env: Env, store: JourneyStore): Promise<Response> => {\n const url = new URL(request.url);\n // Opened by recipients, so no bearer: the routes only record, and redirect to recorded URLs\n if (url.pathname.startsWith('/t/') && store.reporting !== undefined) {\n const openedEvent = config.webPush?.openedEvent;\n const tracked = await handleTracking(request, store.reporting, {\n legacyUnsubscribeUrl: config.email?.unsubscribeUrl,\n product: config.title,\n quickMs: config.email?.tracking?.quickClickMs,\n pushOpened:\n openedEvent === undefined\n ? undefined\n : async ({ messageId, userId, at }) => {\n const payload: Record<string, unknown> = { message_id: messageId };\n const contract = config.contract;\n const verdict =\n contract === undefined ? undefined : checkEvent(contract, openedEvent, payload);\n if (verdict !== undefined && !verdict.ok) {\n throw new ContractValidationError(openedEvent, verdict.issues);\n }\n // Keyed by the copy: a second tap of the same notification is the same event\n await ingestEvent(\n env,\n {\n id: `${openedEvent}:${messageId}`,\n userId,\n event: openedEvent,\n payload: verdict?.data ?? payload,\n ts: at,\n },\n store\n );\n },\n });\n if (tracked !== undefined) return tracked;\n }\n if (url.pathname === '/segments/sweep') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'POST') return json({ error: 'method not allowed' }, 405);\n return json(await sweepSegments(env, store));\n }\n if (url.pathname === '/bundle') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'GET') return json({ error: 'method not allowed' }, 405);\n return json(bundle());\n }\n if (url.pathname === '/bundle/deploy') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'POST') return json({ error: 'method not allowed' }, 405);\n try {\n return json(await deployBundle(env, bundle(), store));\n } catch (error) {\n if (!(error instanceof SchemaMismatchError)) throw error;\n return json({ error: error.message, problems: error.problems }, 503);\n }\n }\n if (emails !== undefined && preview !== undefined && url.pathname.startsWith('/preview/')) {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'GET') return json({ error: 'method not allowed' }, 405);\n const key = url.pathname.slice('/preview/'.length);\n if (!emailIds.has(key)) return json({ error: `unknown email template: ${key}` }, 404);\n // `?props=<JSON object>`: JSON keeps numbers and booleans typed, which query strings cannot\n let props: unknown = {};\n try {\n props = JSON.parse(url.searchParams.get('props') ?? '{}');\n } catch {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n if (typeof props !== 'object' || props === null || Array.isArray(props)) {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n try {\n const { subject, html } = await preview(key, props as Record<string, ScalarIR>);\n return new Response(html, {\n headers: { 'content-type': 'text/html; charset=utf-8', 'x-subject': subject },\n });\n } catch (error) {\n // The query string is the props: a refusal is the caller's to fix, with the field paths\n if (error instanceof TemplateValidationError) {\n return json({ error: error.message, issues: error.issues }, 400);\n }\n throw error;\n }\n }\n return handleRequest(request, env, store, {\n contract: config.contract,\n mode: options.contractMode,\n });\n };\n\n const sweep = async (env: Env) => {\n const { store, close } = lease(env);\n try {\n return await sweepSegments(env, store);\n } finally {\n await close();\n }\n };\n\n // The bundle compiled into this version goes live on its own, once per process: the first\n // request or scheduled run reads which version's bundle is live (one row) and applies this one\n // only over an older version, so one still serving during a rollout can never put its routes\n // back. A failure (say the migrations have not run) is logged and retried a minute later. Only\n // the request that starts the check gets its promise back: a request context cannot wait on\n // another's I/O.\n let applying: Promise<void> | undefined;\n let retryAt = 0;\n const applyOwnBundle = (env: Env, version: BundleVersion | undefined) => {\n if (version === undefined || version.id === '' || Number.isNaN(version.createdAt)) {\n return undefined;\n }\n if (applying !== undefined || Date.now() < retryAt) return undefined;\n applying = (async () => {\n const { store, close } = lease(env);\n try {\n if (store.liveBundleVersion === undefined) return; // D1: POST /bundle/deploy\n const live = await store.liveBundleVersion();\n if (live !== undefined && (live.id === version.id || live.createdAt >= version.createdAt))\n return;\n const result = await deployBundle(env, bundle(), store, version);\n if (result.applied) console.log(`[workflow] version ${version.id} is live`);\n } finally {\n await close();\n }\n })().catch((error: unknown) => {\n applying = undefined;\n retryAt = Date.now() + 60_000;\n console.error(`[workflow] could not make version ${version.id} live`, error);\n });\n return applying;\n };\n\n return { config, checkEnv, lease, bundle, runParts, handle, sweep, applyOwnBundle };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AA8FA,IAAa,qBAAb,cAAwC,MAAM;CAEvB;CADrB,OAAyB;CACzB,YAAY,QAAoC;EAC9C,MAAM,yCAAyC,OAAO,KAAK,MAAM,GAAG;EADjD,KAAA,SAAA;CAErB;AACF;AA8BA,SAAS,WAAW,OAAmC;CACrD,OAAO,MAAM,MAAM,SAAS;EAC1B,QAAQ,KAAK,MAAb;GACE,KAAK,WACH,OAAO,KAAK,YAAY;GAC1B,KAAK,cACH,OAAO,MAAM,QAAQ,KAAK,SAAS,KAAK,WAAW,KAAK,SAAS;GACnE,KAAK,UACH,OACE,KAAK,MAAM,MAAM,WAAW,WAAW,OAAO,IAAI,CAAC,KAAK,WAAW,KAAK,aAAa,CAAC,CAAC;GAE3F,KAAK,UACH,OAAO,KAAK,KAAK,MAAM,QAAQ,WAAW,IAAI,IAAI,CAAC;GACrD,SACE,OAAO;EACX;CACF,CAAC;AACH;AAEA,SAAgB,kBACd,SACA,WAA4B,CAAC,GACX;CAClB,MAAM,EAAE,WAAW;CACnB,MAAM,WAAW,eAAe,SAAS,OAAO,WAAW,KAAK;CAEhE,kBAAkB,QAAQ,SAAS,OAAO,CAAC;CAE3C,IAAI,QAAQ,UAAU,KAAA,KAAa,QAAQ,aAAa,KAAA,GAAW;EACjE,MAAM,WAAW,cAAc,MAAM,CAAC,CAAC,UAAU,QAAQ,aACvD,WAAW,SAAS,IAAI,CAC1B;EACA,IAAI,SAAS,SAAS,GACpB,MAAM,IAAI,UACR,aAAa,SAAS,KAAK,MAAM,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,2IAE3D;CAEJ;CAEA,MAAM,0BAAU,IAAI,QAAqB;CACzC,MAAM,YAAY,QAAkB;EAClC,IAAI,QAAQ,QAAQ,KAAA,GAAW,OAAO;EACtC,MAAM,SAAS,QAAQ,IAAI,GAAG;EAC9B,IAAI,WAAW,KAAA,GAAW,OAAO;EACjC,MAAM,SAAS,EAAE,UAAU,QAAQ,KAAK,GAAG;EAC3C,IAAI,CAAC,OAAO,SAEV,MAAM,IAAI,mBACR,OAAO,MAAM,OAAO,KAAK,UAAU,GAAG,MAAM,KAAK,KAAK,GAAG,KAAK,SAAS,IAAI,MAAM,SAAS,CAC5F;EAEF,MAAM,SAAc;GAAE,GAAG;GAAK,GAAG,OAAO;EAAK;EAC7C,QAAQ,IAAI,KAAK,MAAM;EACvB,OAAO;CACT;CACA,IAAI;CACJ,MAAM,eAAgB,aAAa,cAAc,MAAM;CACvD,MAAM,SAAS,OAAO,WAAW;CACjC,MAAM,WAAW,EAAE,QAAQ,OAAO,UAAU,UAAA,WAAyB;CAErE,MAAM,SAAS,QAAmC;EAChD,MAAM,SAAS,QAAQ,UAAU,KAAA,IAAY,QAAQ,GAAG,IAAI,QAAQ,MAAM,KAAK,QAAQ;EACvF,MAAM,OAAO,YAAY,CAAC;EAC1B,MAAM,SAAS,kBAAkB,QAAQ,GAAG,CAAC,CAAC;EAC9C,OAAO,WAAW,SACd;GAAE,OAAO,oBAAoB,OAAO,OAAO,MAAM;GAAG,OAAO,OAAO,SAAS;EAAK,IAChF;GAAE,OAAO,oBAAoB,QAAQ,MAAM;GAAG,OAAO;EAAK;CAChE;CAEA,MAAM,YAAY,KAAU,UAAuC;EACjE,IAAI,QAAQ,aAAa,KAAA,GAAW,OAAO,QAAQ,SAAS,KAAK,KAAK;EACtE,OAAO,mBAAmB,QAAQ,KAAK;GACrC;GACA,OAAO,QAAQ,QAAQ,GAAG;GAC1B,WAAW,SAAS;EACtB,CAAC;CACH;CAEA,MAAM,YAAY,KAAU,UAAqD;EAC/E,IAAI,QAAQ,aAAa,KAAA,GAAW,OAAO,QAAQ,SAAS,KAAK,KAAK;EACtE,OAAO,MAAM,cAAc,KAAA,IACvB,KAAA,IACA,kBAAkB,MAAM,WAAW,IAAI,eAAe;CAC5D;CAEA,MAAM,YAAY,KAAU,OAAqB,YAA4C;EAC3F;EACA;EACA,SACE,QAAQ,YAAY,KAAA,IAAY,kBAAkB,QAAQ,GAAG,IAAI,QAAQ,QAAQ,KAAK,MAAM;EAC9F,UAAU,SAAS,KAAK,KAAK;EAE7B,SAAS,IAAI,sBAAsB,OAAO,WAAW,CAAC,CAAC;EACvD,UAAU,OAAO;EACjB,WAAW,kBAAkB,OAAO,aAAa,CAAC,CAAC;EACnD,UAAU,SAAS,KAAK,KAAK;CAC/B;CAEA,MAAM,QAAQ,MAAe,SAAS,QACpC,IAAI,SAAS,KAAK,UAAU,IAAI,GAAG;EACjC;EACA,SAAS,EAAE,gBAAgB,mBAAmB;CAChD,CAAC;CAEH,MAAM,cAAc,SAAkB,QACpC,IAAI,cAAc,KAAA,KAClB,QAAQ,QAAQ,IAAI,eAAe,MAAM,UAAU,IAAI;CAEzD,MAAM,UAAU,WAAW,KAAA,IAAY,KAAA,IAAY,iBAAiB,QAAQ,OAAO,KAAK;CAExF,MAAM,SAAS,OAAO,SAAkB,KAAU,UAA2C;EAC3F,MAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;EAE/B,IAAI,IAAI,SAAS,WAAW,KAAK,KAAK,MAAM,cAAc,KAAA,GAAW;GACnE,MAAM,cAAc,OAAO,SAAS;GACpC,MAAM,UAAU,MAAM,eAAe,SAAS,MAAM,WAAW;IAC7D,sBAAsB,OAAO,OAAO;IACpC,SAAS,OAAO;IAChB,SAAS,OAAO,OAAO,UAAU;IACjC,YACE,gBAAgB,KAAA,IACZ,KAAA,IACA,OAAO,EAAE,WAAW,QAAQ,SAAS;KACnC,MAAM,UAAmC,EAAE,YAAY,UAAU;KACjE,MAAM,WAAW,OAAO;KACxB,MAAM,UACJ,aAAa,KAAA,IAAY,KAAA,IAAY,WAAW,UAAU,aAAa,OAAO;KAChF,IAAI,YAAY,KAAA,KAAa,CAAC,QAAQ,IACpC,MAAM,IAAI,wBAAwB,aAAa,QAAQ,MAAM;KAG/D,MAAM,YACJ,KACA;MACE,IAAI,GAAG,YAAY,GAAG;MACtB;MACA,OAAO;MACP,SAAS,SAAS,QAAQ;MAC1B,IAAI;KACN,GACA,KACF;IACF;GACR,CAAC;GACD,IAAI,YAAY,KAAA,GAAW,OAAO;EACpC;EACA,IAAI,IAAI,aAAa,mBAAmB;GACtC,IAAI,CAAC,WAAW,SAAS,GAAG,GAAG,OAAO,KAAK,EAAE,OAAO,eAAe,GAAG,GAAG;GACzE,IAAI,QAAQ,WAAW,QAAQ,OAAO,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;GAC/E,OAAO,KAAK,MAAM,cAAc,KAAK,KAAK,CAAC;EAC7C;EACA,IAAI,IAAI,aAAa,WAAW;GAC9B,IAAI,CAAC,WAAW,SAAS,GAAG,GAAG,OAAO,KAAK,EAAE,OAAO,eAAe,GAAG,GAAG;GACzE,IAAI,QAAQ,WAAW,OAAO,OAAO,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;GAC9E,OAAO,KAAK,OAAO,CAAC;EACtB;EACA,IAAI,IAAI,aAAa,kBAAkB;GACrC,IAAI,CAAC,WAAW,SAAS,GAAG,GAAG,OAAO,KAAK,EAAE,OAAO,eAAe,GAAG,GAAG;GACzE,IAAI,QAAQ,WAAW,QAAQ,OAAO,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;GAC/E,IAAI;IACF,OAAO,KAAK,MAAM,aAAa,KAAK,OAAO,GAAG,KAAK,CAAC;GACtD,SAAS,OAAO;IACd,IAAI,EAAE,iBAAiB,sBAAsB,MAAM;IACnD,OAAO,KAAK;KAAE,OAAO,MAAM;KAAS,UAAU,MAAM;IAAS,GAAG,GAAG;GACrE;EACF;EACA,IAAI,WAAW,KAAA,KAAa,YAAY,KAAA,KAAa,IAAI,SAAS,WAAW,WAAW,GAAG;GACzF,IAAI,CAAC,WAAW,SAAS,GAAG,GAAG,OAAO,KAAK,EAAE,OAAO,eAAe,GAAG,GAAG;GACzE,IAAI,QAAQ,WAAW,OAAO,OAAO,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;GAC9E,MAAM,MAAM,IAAI,SAAS,MAAM,CAAkB;GACjD,IAAI,CAAC,SAAS,IAAI,GAAG,GAAG,OAAO,KAAK,EAAE,OAAO,2BAA2B,MAAM,GAAG,GAAG;GAEpF,IAAI,QAAiB,CAAC;GACtB,IAAI;IACF,QAAQ,KAAK,MAAM,IAAI,aAAa,IAAI,OAAO,KAAK,IAAI;GAC1D,QAAQ;IACN,OAAO,KAAK,EAAE,OAAO,8BAA8B,GAAG,GAAG;GAC3D;GACA,IAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,MAAM,QAAQ,KAAK,GACpE,OAAO,KAAK,EAAE,OAAO,8BAA8B,GAAG,GAAG;GAE3D,IAAI;IACF,MAAM,EAAE,SAAS,SAAS,MAAM,QAAQ,KAAK,KAAiC;IAC9E,OAAO,IAAI,SAAS,MAAM,EACxB,SAAS;KAAE,gBAAgB;KAA4B,aAAa;IAAQ,EAC9E,CAAC;GACH,SAAS,OAAO;IAEd,IAAI,iBAAiB,yBACnB,OAAO,KAAK;KAAE,OAAO,MAAM;KAAS,QAAQ,MAAM;IAAO,GAAG,GAAG;IAEjE,MAAM;GACR;EACF;EACA,OAAO,cAAc,SAAS,KAAK,OAAO;GACxC,UAAU,OAAO;GACjB,MAAM,QAAQ;EAChB,CAAC;CACH;CAEA,MAAM,QAAQ,OAAO,QAAa;EAChC,MAAM,EAAE,OAAO,UAAU,MAAM,GAAG;EAClC,IAAI;GACF,OAAO,MAAM,cAAc,KAAK,KAAK;EACvC,UAAU;GACR,MAAM,MAAM;EACd;CACF;CAQA,IAAI;CACJ,IAAI,UAAU;CACd,MAAM,kBAAkB,KAAU,YAAuC;EACvE,IAAI,YAAY,KAAA,KAAa,QAAQ,OAAO,MAAM,OAAO,MAAM,QAAQ,SAAS,GAC9E;EAEF,IAAI,aAAa,KAAA,KAAa,KAAK,IAAI,IAAI,SAAS,OAAO,KAAA;EAC3D,YAAY,YAAY;GACtB,MAAM,EAAE,OAAO,UAAU,MAAM,GAAG;GAClC,IAAI;IACF,IAAI,MAAM,sBAAsB,KAAA,GAAW;IAC3C,MAAM,OAAO,MAAM,MAAM,kBAAkB;IAC3C,IAAI,SAAS,KAAA,MAAc,KAAK,OAAO,QAAQ,MAAM,KAAK,aAAa,QAAQ,YAC7E;IAEF,KAAI,MADiB,aAAa,KAAK,OAAO,GAAG,OAAO,OAAO,EAAA,CACpD,SAAS,QAAQ,IAAI,sBAAsB,QAAQ,GAAG,SAAS;GAC5E,UAAU;IACR,MAAM,MAAM;GACd;EACF,EAAA,CAAG,CAAC,CAAC,OAAO,UAAmB;GAC7B,WAAW,KAAA;GACX,UAAU,KAAK,IAAI,IAAI;GACvB,QAAQ,MAAM,qCAAqC,QAAQ,GAAG,QAAQ,KAAK;EAC7E,CAAC;EACD,OAAO;CACT;CAEA,OAAO;EAAE;EAAQ;EAAU;EAAO;EAAQ;EAAU;EAAQ;EAAO;CAAe;AACpF"}
@@ -1,16 +1,20 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_email = require("../email.cjs");
3
+ const require_engine_retry = require("../engine/retry.cjs");
3
4
  //#region src/transports/cloudflare-email.ts
4
5
  /** The REST API refused the email: `errors` are Cloudflare's `{ code, message }` pairs. */
5
6
  var CloudflareEmailError = class extends Error {
6
7
  status;
7
8
  errors;
8
9
  name = "CloudflareEmailError";
10
+ /** A refused request (bad token, invalid schema) fails the same way every time. */
11
+ retryable;
9
12
  constructor(status, errors) {
10
13
  const said = errors.map((e) => `${e.code ?? "?"} ${e.message ?? ""}`.trim()).join("; ");
11
14
  super(`Cloudflare Email send failed (${status})${said === "" ? "" : `: ${said}`}`);
12
15
  this.status = status;
13
16
  this.errors = errors;
17
+ this.retryable = status < 400 || require_engine_retry.isRetryableStatus(status);
14
18
  }
15
19
  };
16
20
  function cloudflareEmailTransport(options) {
@@ -1 +1 @@
1
- {"version":3,"file":"cloudflare-email.cjs","names":["mimeAddress"],"sources":["../../src/transports/cloudflare-email.ts"],"sourcesContent":["import type { EmailBindingLike } from '../cloudflare/bindings';\nimport { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\n\n/**\n * `@shware/workflow/transports/cloudflare-email` — Cloudflare Email Service, through the\n * Worker's `send_email` binding or, from anywhere else (a Lambda), its REST API. Both take the\n * same finished email; only the call and the way each reports a failure differ.\n */\n\nexport type CloudflareEmailTransportOptions =\n | {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n accountId?: never;\n apiToken?: never;\n }\n | {\n binding?: never;\n accountId: string;\n /** An API token allowed to send email for the account. */\n apiToken: string;\n /** Defaults to `https://api.cloudflare.com/client/v4`. */\n endpoint?: string;\n fetch?: typeof fetch;\n };\n\n/** The REST API refused the email: `errors` are Cloudflare's `{ code, message }` pairs. */\nexport class CloudflareEmailError extends Error {\n override readonly name = 'CloudflareEmailError';\n constructor(\n readonly status: number,\n readonly errors: readonly { code?: number; message?: string }[]\n ) {\n const said = errors.map((e) => `${e.code ?? '?'} ${e.message ?? ''}`.trim()).join('; ');\n super(`Cloudflare Email send failed (${status})${said === '' ? '' : `: ${said}`}`);\n }\n}\n\nexport function cloudflareEmailTransport(options: CloudflareEmailTransportOptions): EmailTransport {\n if (options.binding !== undefined) {\n const { binding } = options;\n return {\n async send(message) {\n // The binding takes the key and drops the duplicate a retry or replay produces\n const result: unknown = await binding.send(message);\n const id = (result as { messageId?: unknown } | null | undefined)?.messageId;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n }\n // Checked at runtime too: a JS caller can pass neither\n const given = options as { accountId?: unknown; apiToken?: unknown };\n if (typeof given.accountId !== 'string' || typeof given.apiToken !== 'string') {\n throw new TypeError('cloudflareEmailTransport needs { binding } or { accountId, apiToken }');\n }\n const { accountId, apiToken } = options;\n const endpoint = (options.endpoint ?? 'https://api.cloudflare.com/client/v4').replace(/\\/$/, '');\n const fetchImpl = options.fetch ?? fetch;\n return {\n async send(message: EmailMessage) {\n const response = await fetchImpl(\n `${endpoint}/accounts/${encodeURIComponent(accountId)}/email/sending/send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${apiToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n from: mimeAddress(message.from),\n to: message.to,\n ...(message.replyTo === undefined ? {} : { reply_to: mimeAddress(message.replyTo) }),\n subject: message.subject,\n html: message.html,\n }),\n }\n );\n let body: { success?: unknown; errors?: unknown; result?: { message_id?: unknown } } = {};\n try {\n body = (await response.json()) as typeof body;\n } catch {\n body = {};\n }\n if (!response.ok || body.success !== true) {\n throw new CloudflareEmailError(\n response.status,\n Array.isArray(body.errors) ? (body.errors as { code?: number; message?: string }[]) : []\n );\n }\n // No server-side de-duplication is documented for the REST API: a retry after an accepted\n // send can deliver twice (at least once, like SES)\n const id = body.result?.message_id;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n}\n"],"mappings":";;;;AA4BA,IAAa,uBAAb,cAA0C,MAAM;CAGnC;CACA;CAHX,OAAyB;CACzB,YACE,QACA,QACA;EACA,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,EAAE,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;EACtF,MAAM,iCAAiC,OAAO,GAAG,SAAS,KAAK,KAAK,KAAK,QAAQ;EAJxE,KAAA,SAAA;EACA,KAAA,SAAA;CAIX;AACF;AAEA,SAAgB,yBAAyB,SAA0D;CACjG,IAAI,QAAQ,YAAY,KAAA,GAAW;EACjC,MAAM,EAAE,YAAY;EACpB,OAAO,EACL,MAAM,KAAK,SAAS;GAGlB,MAAM,MAAM,MADkB,QAAQ,KAAK,OAAO,EAAA,EACiB;GACnE,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;EAC/D,EACF;CACF;CAEA,MAAM,QAAQ;CACd,IAAI,OAAO,MAAM,cAAc,YAAY,OAAO,MAAM,aAAa,UACnE,MAAM,IAAI,UAAU,uEAAuE;CAE7F,MAAM,EAAE,WAAW,aAAa;CAChC,MAAM,YAAY,QAAQ,YAAY,uCAAA,CAAwC,QAAQ,OAAO,EAAE;CAC/F,MAAM,YAAY,QAAQ,SAAS;CACnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,WAAW,MAAM,UACrB,GAAG,SAAS,YAAY,mBAAmB,SAAS,EAAE,sBACtD;GACE,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;GAClB;GACA,MAAM,KAAK,UAAU;IACnB,MAAMA,cAAAA,YAAY,QAAQ,IAAI;IAC9B,IAAI,QAAQ;IACZ,GAAI,QAAQ,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,UAAUA,cAAAA,YAAY,QAAQ,OAAO,EAAE;IAClF,SAAS,QAAQ;IACjB,MAAM,QAAQ;GAChB,CAAC;EACH,CACF;EACA,IAAI,OAAmF,CAAC;EACxF,IAAI;GACF,OAAQ,MAAM,SAAS,KAAK;EAC9B,QAAQ;GACN,OAAO,CAAC;EACV;EACA,IAAI,CAAC,SAAS,MAAM,KAAK,YAAY,MACnC,MAAM,IAAI,qBACR,SAAS,QACT,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAmD,CAAC,CACzF;EAIF,MAAM,KAAK,KAAK,QAAQ;EACxB,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;CAC/D,EACF;AACF"}
1
+ {"version":3,"file":"cloudflare-email.cjs","names":["isRetryableStatus","mimeAddress"],"sources":["../../src/transports/cloudflare-email.ts"],"sourcesContent":["import type { EmailBindingLike } from '../cloudflare/bindings';\nimport { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { isRetryableStatus } from '../engine/retry';\n\n/**\n * `@shware/workflow/transports/cloudflare-email` — Cloudflare Email Service, through the\n * Worker's `send_email` binding or, from anywhere else (a Lambda), its REST API. Both take the\n * same finished email; only the call and the way each reports a failure differ.\n */\n\nexport type CloudflareEmailTransportOptions =\n | {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n accountId?: never;\n apiToken?: never;\n }\n | {\n binding?: never;\n accountId: string;\n /** An API token allowed to send email for the account. */\n apiToken: string;\n /** Defaults to `https://api.cloudflare.com/client/v4`. */\n endpoint?: string;\n fetch?: typeof fetch;\n };\n\n/** The REST API refused the email: `errors` are Cloudflare's `{ code, message }` pairs. */\nexport class CloudflareEmailError extends Error {\n override readonly name = 'CloudflareEmailError';\n /** A refused request (bad token, invalid schema) fails the same way every time. */\n readonly retryable: boolean;\n constructor(\n readonly status: number,\n readonly errors: readonly { code?: number; message?: string }[]\n ) {\n const said = errors.map((e) => `${e.code ?? '?'} ${e.message ?? ''}`.trim()).join('; ');\n super(`Cloudflare Email send failed (${status})${said === '' ? '' : `: ${said}`}`);\n // A 200 with `success: false` says nothing about retrying: treat it as transient\n this.retryable = status < 400 || isRetryableStatus(status);\n }\n}\n\nexport function cloudflareEmailTransport(options: CloudflareEmailTransportOptions): EmailTransport {\n if (options.binding !== undefined) {\n const { binding } = options;\n return {\n async send(message) {\n // The binding takes the key and drops the duplicate a retry or replay produces\n const result: unknown = await binding.send(message);\n const id = (result as { messageId?: unknown } | null | undefined)?.messageId;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n }\n // Checked at runtime too: a JS caller can pass neither\n const given = options as { accountId?: unknown; apiToken?: unknown };\n if (typeof given.accountId !== 'string' || typeof given.apiToken !== 'string') {\n throw new TypeError('cloudflareEmailTransport needs { binding } or { accountId, apiToken }');\n }\n const { accountId, apiToken } = options;\n const endpoint = (options.endpoint ?? 'https://api.cloudflare.com/client/v4').replace(/\\/$/, '');\n const fetchImpl = options.fetch ?? fetch;\n return {\n async send(message: EmailMessage) {\n const response = await fetchImpl(\n `${endpoint}/accounts/${encodeURIComponent(accountId)}/email/sending/send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${apiToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n from: mimeAddress(message.from),\n to: message.to,\n ...(message.replyTo === undefined ? {} : { reply_to: mimeAddress(message.replyTo) }),\n subject: message.subject,\n html: message.html,\n }),\n }\n );\n let body: { success?: unknown; errors?: unknown; result?: { message_id?: unknown } } = {};\n try {\n body = (await response.json()) as typeof body;\n } catch {\n body = {};\n }\n if (!response.ok || body.success !== true) {\n throw new CloudflareEmailError(\n response.status,\n Array.isArray(body.errors) ? (body.errors as { code?: number; message?: string }[]) : []\n );\n }\n // No server-side de-duplication is documented for the REST API: a retry after an accepted\n // send can deliver twice (at least once, like SES)\n const id = body.result?.message_id;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n}\n"],"mappings":";;;;;AA6BA,IAAa,uBAAb,cAA0C,MAAM;CAKnC;CACA;CALX,OAAyB;;CAEzB;CACA,YACE,QACA,QACA;EACA,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,EAAE,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;EACtF,MAAM,iCAAiC,OAAO,GAAG,SAAS,KAAK,KAAK,KAAK,QAAQ;EAJxE,KAAA,SAAA;EACA,KAAA,SAAA;EAKT,KAAK,YAAY,SAAS,OAAOA,qBAAAA,kBAAkB,MAAM;CAC3D;AACF;AAEA,SAAgB,yBAAyB,SAA0D;CACjG,IAAI,QAAQ,YAAY,KAAA,GAAW;EACjC,MAAM,EAAE,YAAY;EACpB,OAAO,EACL,MAAM,KAAK,SAAS;GAGlB,MAAM,MAAM,MADkB,QAAQ,KAAK,OAAO,EAAA,EACiB;GACnE,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;EAC/D,EACF;CACF;CAEA,MAAM,QAAQ;CACd,IAAI,OAAO,MAAM,cAAc,YAAY,OAAO,MAAM,aAAa,UACnE,MAAM,IAAI,UAAU,uEAAuE;CAE7F,MAAM,EAAE,WAAW,aAAa;CAChC,MAAM,YAAY,QAAQ,YAAY,uCAAA,CAAwC,QAAQ,OAAO,EAAE;CAC/F,MAAM,YAAY,QAAQ,SAAS;CACnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,WAAW,MAAM,UACrB,GAAG,SAAS,YAAY,mBAAmB,SAAS,EAAE,sBACtD;GACE,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;GAClB;GACA,MAAM,KAAK,UAAU;IACnB,MAAMC,cAAAA,YAAY,QAAQ,IAAI;IAC9B,IAAI,QAAQ;IACZ,GAAI,QAAQ,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,UAAUA,cAAAA,YAAY,QAAQ,OAAO,EAAE;IAClF,SAAS,QAAQ;IACjB,MAAM,QAAQ;GAChB,CAAC;EACH,CACF;EACA,IAAI,OAAmF,CAAC;EACxF,IAAI;GACF,OAAQ,MAAM,SAAS,KAAK;EAC9B,QAAQ;GACN,OAAO,CAAC;EACV;EACA,IAAI,CAAC,SAAS,MAAM,KAAK,YAAY,MACnC,MAAM,IAAI,qBACR,SAAS,QACT,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAmD,CAAC,CACzF;EAIF,MAAM,KAAK,KAAK,QAAQ;EACxB,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;CAC/D,EACF;AACF"}
@@ -28,6 +28,8 @@ export declare class CloudflareEmailError extends Error {
28
28
  message?: string;
29
29
  }[];
30
30
  readonly name = "CloudflareEmailError";
31
+ /** A refused request (bad token, invalid schema) fails the same way every time. */
32
+ readonly retryable: boolean;
31
33
  constructor(status: number, errors: readonly {
32
34
  code?: number;
33
35
  message?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"cloudflare-email.d.cts","names":[],"sources":["../../src/transports/cloudflare-email.ts"],"mappings":";;;;;;;;YAUY;;EAGN,SAAS;EACT;EACA;;EAGA;EACA;;EAEA;;EAEA;EACA,eAAe;;;qBAIR,6BAA6B;WAG7B;WACA;IAAmB;IAAe;;WAH3B;EAClB,YACW,gBACA;IAAmB;IAAe;;;wBAO/B,yBAAyB,SAAS,kCAAkC"}
1
+ {"version":3,"file":"cloudflare-email.d.cts","names":[],"sources":["../../src/transports/cloudflare-email.ts"],"mappings":";;;;;;;;YAWY;;EAGN,SAAS;EACT;EACA;;EAGA;EACA;;EAEA;;EAEA;EACA,eAAe;;;qBAIR,6BAA6B;WAK7B;WACA;IAAmB;IAAe;;WAL3B;;WAET;EACT,YACW,gBACA;IAAmB;IAAe;;;wBAS/B,yBAAyB,SAAS,kCAAkC"}
@@ -28,6 +28,8 @@ export declare class CloudflareEmailError extends Error {
28
28
  message?: string;
29
29
  }[];
30
30
  readonly name = "CloudflareEmailError";
31
+ /** A refused request (bad token, invalid schema) fails the same way every time. */
32
+ readonly retryable: boolean;
31
33
  constructor(status: number, errors: readonly {
32
34
  code?: number;
33
35
  message?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"cloudflare-email.d.mts","names":[],"sources":["../../src/transports/cloudflare-email.ts"],"mappings":";;;;;;;;YAUY;;EAGN,SAAS;EACT;EACA;;EAGA;EACA;;EAEA;;EAEA;EACA,eAAe;;;qBAIR,6BAA6B;WAG7B;WACA;IAAmB;IAAe;;WAH3B;EAClB,YACW,gBACA;IAAmB;IAAe;;;wBAO/B,yBAAyB,SAAS,kCAAkC"}
1
+ {"version":3,"file":"cloudflare-email.d.mts","names":[],"sources":["../../src/transports/cloudflare-email.ts"],"mappings":";;;;;;;;YAWY;;EAGN,SAAS;EACT;EACA;;EAGA;EACA;;EAEA;;EAEA;EACA,eAAe;;;qBAIR,6BAA6B;WAK7B;WACA;IAAmB;IAAe;;WAL3B;;WAET;EACT,YACW,gBACA;IAAmB;IAAe;;;wBAS/B,yBAAyB,SAAS,kCAAkC"}
@@ -1,15 +1,19 @@
1
1
  import { mimeAddress } from "../email.mjs";
2
+ import { isRetryableStatus } from "../engine/retry.mjs";
2
3
  //#region src/transports/cloudflare-email.ts
3
4
  /** The REST API refused the email: `errors` are Cloudflare's `{ code, message }` pairs. */
4
5
  var CloudflareEmailError = class extends Error {
5
6
  status;
6
7
  errors;
7
8
  name = "CloudflareEmailError";
9
+ /** A refused request (bad token, invalid schema) fails the same way every time. */
10
+ retryable;
8
11
  constructor(status, errors) {
9
12
  const said = errors.map((e) => `${e.code ?? "?"} ${e.message ?? ""}`.trim()).join("; ");
10
13
  super(`Cloudflare Email send failed (${status})${said === "" ? "" : `: ${said}`}`);
11
14
  this.status = status;
12
15
  this.errors = errors;
16
+ this.retryable = status < 400 || isRetryableStatus(status);
13
17
  }
14
18
  };
15
19
  function cloudflareEmailTransport(options) {
@@ -1 +1 @@
1
- {"version":3,"file":"cloudflare-email.mjs","names":[],"sources":["../../src/transports/cloudflare-email.ts"],"sourcesContent":["import type { EmailBindingLike } from '../cloudflare/bindings';\nimport { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\n\n/**\n * `@shware/workflow/transports/cloudflare-email` — Cloudflare Email Service, through the\n * Worker's `send_email` binding or, from anywhere else (a Lambda), its REST API. Both take the\n * same finished email; only the call and the way each reports a failure differ.\n */\n\nexport type CloudflareEmailTransportOptions =\n | {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n accountId?: never;\n apiToken?: never;\n }\n | {\n binding?: never;\n accountId: string;\n /** An API token allowed to send email for the account. */\n apiToken: string;\n /** Defaults to `https://api.cloudflare.com/client/v4`. */\n endpoint?: string;\n fetch?: typeof fetch;\n };\n\n/** The REST API refused the email: `errors` are Cloudflare's `{ code, message }` pairs. */\nexport class CloudflareEmailError extends Error {\n override readonly name = 'CloudflareEmailError';\n constructor(\n readonly status: number,\n readonly errors: readonly { code?: number; message?: string }[]\n ) {\n const said = errors.map((e) => `${e.code ?? '?'} ${e.message ?? ''}`.trim()).join('; ');\n super(`Cloudflare Email send failed (${status})${said === '' ? '' : `: ${said}`}`);\n }\n}\n\nexport function cloudflareEmailTransport(options: CloudflareEmailTransportOptions): EmailTransport {\n if (options.binding !== undefined) {\n const { binding } = options;\n return {\n async send(message) {\n // The binding takes the key and drops the duplicate a retry or replay produces\n const result: unknown = await binding.send(message);\n const id = (result as { messageId?: unknown } | null | undefined)?.messageId;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n }\n // Checked at runtime too: a JS caller can pass neither\n const given = options as { accountId?: unknown; apiToken?: unknown };\n if (typeof given.accountId !== 'string' || typeof given.apiToken !== 'string') {\n throw new TypeError('cloudflareEmailTransport needs { binding } or { accountId, apiToken }');\n }\n const { accountId, apiToken } = options;\n const endpoint = (options.endpoint ?? 'https://api.cloudflare.com/client/v4').replace(/\\/$/, '');\n const fetchImpl = options.fetch ?? fetch;\n return {\n async send(message: EmailMessage) {\n const response = await fetchImpl(\n `${endpoint}/accounts/${encodeURIComponent(accountId)}/email/sending/send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${apiToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n from: mimeAddress(message.from),\n to: message.to,\n ...(message.replyTo === undefined ? {} : { reply_to: mimeAddress(message.replyTo) }),\n subject: message.subject,\n html: message.html,\n }),\n }\n );\n let body: { success?: unknown; errors?: unknown; result?: { message_id?: unknown } } = {};\n try {\n body = (await response.json()) as typeof body;\n } catch {\n body = {};\n }\n if (!response.ok || body.success !== true) {\n throw new CloudflareEmailError(\n response.status,\n Array.isArray(body.errors) ? (body.errors as { code?: number; message?: string }[]) : []\n );\n }\n // No server-side de-duplication is documented for the REST API: a retry after an accepted\n // send can deliver twice (at least once, like SES)\n const id = body.result?.message_id;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n}\n"],"mappings":";;;AA4BA,IAAa,uBAAb,cAA0C,MAAM;CAGnC;CACA;CAHX,OAAyB;CACzB,YACE,QACA,QACA;EACA,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,EAAE,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;EACtF,MAAM,iCAAiC,OAAO,GAAG,SAAS,KAAK,KAAK,KAAK,QAAQ;EAJxE,KAAA,SAAA;EACA,KAAA,SAAA;CAIX;AACF;AAEA,SAAgB,yBAAyB,SAA0D;CACjG,IAAI,QAAQ,YAAY,KAAA,GAAW;EACjC,MAAM,EAAE,YAAY;EACpB,OAAO,EACL,MAAM,KAAK,SAAS;GAGlB,MAAM,MAAM,MADkB,QAAQ,KAAK,OAAO,EAAA,EACiB;GACnE,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;EAC/D,EACF;CACF;CAEA,MAAM,QAAQ;CACd,IAAI,OAAO,MAAM,cAAc,YAAY,OAAO,MAAM,aAAa,UACnE,MAAM,IAAI,UAAU,uEAAuE;CAE7F,MAAM,EAAE,WAAW,aAAa;CAChC,MAAM,YAAY,QAAQ,YAAY,uCAAA,CAAwC,QAAQ,OAAO,EAAE;CAC/F,MAAM,YAAY,QAAQ,SAAS;CACnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,WAAW,MAAM,UACrB,GAAG,SAAS,YAAY,mBAAmB,SAAS,EAAE,sBACtD;GACE,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;GAClB;GACA,MAAM,KAAK,UAAU;IACnB,MAAM,YAAY,QAAQ,IAAI;IAC9B,IAAI,QAAQ;IACZ,GAAI,QAAQ,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU,YAAY,QAAQ,OAAO,EAAE;IAClF,SAAS,QAAQ;IACjB,MAAM,QAAQ;GAChB,CAAC;EACH,CACF;EACA,IAAI,OAAmF,CAAC;EACxF,IAAI;GACF,OAAQ,MAAM,SAAS,KAAK;EAC9B,QAAQ;GACN,OAAO,CAAC;EACV;EACA,IAAI,CAAC,SAAS,MAAM,KAAK,YAAY,MACnC,MAAM,IAAI,qBACR,SAAS,QACT,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAmD,CAAC,CACzF;EAIF,MAAM,KAAK,KAAK,QAAQ;EACxB,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;CAC/D,EACF;AACF"}
1
+ {"version":3,"file":"cloudflare-email.mjs","names":[],"sources":["../../src/transports/cloudflare-email.ts"],"sourcesContent":["import type { EmailBindingLike } from '../cloudflare/bindings';\nimport { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { isRetryableStatus } from '../engine/retry';\n\n/**\n * `@shware/workflow/transports/cloudflare-email` — Cloudflare Email Service, through the\n * Worker's `send_email` binding or, from anywhere else (a Lambda), its REST API. Both take the\n * same finished email; only the call and the way each reports a failure differ.\n */\n\nexport type CloudflareEmailTransportOptions =\n | {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n accountId?: never;\n apiToken?: never;\n }\n | {\n binding?: never;\n accountId: string;\n /** An API token allowed to send email for the account. */\n apiToken: string;\n /** Defaults to `https://api.cloudflare.com/client/v4`. */\n endpoint?: string;\n fetch?: typeof fetch;\n };\n\n/** The REST API refused the email: `errors` are Cloudflare's `{ code, message }` pairs. */\nexport class CloudflareEmailError extends Error {\n override readonly name = 'CloudflareEmailError';\n /** A refused request (bad token, invalid schema) fails the same way every time. */\n readonly retryable: boolean;\n constructor(\n readonly status: number,\n readonly errors: readonly { code?: number; message?: string }[]\n ) {\n const said = errors.map((e) => `${e.code ?? '?'} ${e.message ?? ''}`.trim()).join('; ');\n super(`Cloudflare Email send failed (${status})${said === '' ? '' : `: ${said}`}`);\n // A 200 with `success: false` says nothing about retrying: treat it as transient\n this.retryable = status < 400 || isRetryableStatus(status);\n }\n}\n\nexport function cloudflareEmailTransport(options: CloudflareEmailTransportOptions): EmailTransport {\n if (options.binding !== undefined) {\n const { binding } = options;\n return {\n async send(message) {\n // The binding takes the key and drops the duplicate a retry or replay produces\n const result: unknown = await binding.send(message);\n const id = (result as { messageId?: unknown } | null | undefined)?.messageId;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n }\n // Checked at runtime too: a JS caller can pass neither\n const given = options as { accountId?: unknown; apiToken?: unknown };\n if (typeof given.accountId !== 'string' || typeof given.apiToken !== 'string') {\n throw new TypeError('cloudflareEmailTransport needs { binding } or { accountId, apiToken }');\n }\n const { accountId, apiToken } = options;\n const endpoint = (options.endpoint ?? 'https://api.cloudflare.com/client/v4').replace(/\\/$/, '');\n const fetchImpl = options.fetch ?? fetch;\n return {\n async send(message: EmailMessage) {\n const response = await fetchImpl(\n `${endpoint}/accounts/${encodeURIComponent(accountId)}/email/sending/send`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${apiToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n from: mimeAddress(message.from),\n to: message.to,\n ...(message.replyTo === undefined ? {} : { reply_to: mimeAddress(message.replyTo) }),\n subject: message.subject,\n html: message.html,\n }),\n }\n );\n let body: { success?: unknown; errors?: unknown; result?: { message_id?: unknown } } = {};\n try {\n body = (await response.json()) as typeof body;\n } catch {\n body = {};\n }\n if (!response.ok || body.success !== true) {\n throw new CloudflareEmailError(\n response.status,\n Array.isArray(body.errors) ? (body.errors as { code?: number; message?: string }[]) : []\n );\n }\n // No server-side de-duplication is documented for the REST API: a retry after an accepted\n // send can deliver twice (at least once, like SES)\n const id = body.result?.message_id;\n return typeof id === 'string' ? { providerMessageId: id } : {};\n },\n };\n}\n"],"mappings":";;;;AA6BA,IAAa,uBAAb,cAA0C,MAAM;CAKnC;CACA;CALX,OAAyB;;CAEzB;CACA,YACE,QACA,QACA;EACA,MAAM,OAAO,OAAO,KAAK,MAAM,GAAG,EAAE,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;EACtF,MAAM,iCAAiC,OAAO,GAAG,SAAS,KAAK,KAAK,KAAK,QAAQ;EAJxE,KAAA,SAAA;EACA,KAAA,SAAA;EAKT,KAAK,YAAY,SAAS,OAAO,kBAAkB,MAAM;CAC3D;AACF;AAEA,SAAgB,yBAAyB,SAA0D;CACjG,IAAI,QAAQ,YAAY,KAAA,GAAW;EACjC,MAAM,EAAE,YAAY;EACpB,OAAO,EACL,MAAM,KAAK,SAAS;GAGlB,MAAM,MAAM,MADkB,QAAQ,KAAK,OAAO,EAAA,EACiB;GACnE,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;EAC/D,EACF;CACF;CAEA,MAAM,QAAQ;CACd,IAAI,OAAO,MAAM,cAAc,YAAY,OAAO,MAAM,aAAa,UACnE,MAAM,IAAI,UAAU,uEAAuE;CAE7F,MAAM,EAAE,WAAW,aAAa;CAChC,MAAM,YAAY,QAAQ,YAAY,uCAAA,CAAwC,QAAQ,OAAO,EAAE;CAC/F,MAAM,YAAY,QAAQ,SAAS;CACnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,WAAW,MAAM,UACrB,GAAG,SAAS,YAAY,mBAAmB,SAAS,EAAE,sBACtD;GACE,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;GAClB;GACA,MAAM,KAAK,UAAU;IACnB,MAAM,YAAY,QAAQ,IAAI;IAC9B,IAAI,QAAQ;IACZ,GAAI,QAAQ,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU,YAAY,QAAQ,OAAO,EAAE;IAClF,SAAS,QAAQ;IACjB,MAAM,QAAQ;GAChB,CAAC;EACH,CACF;EACA,IAAI,OAAmF,CAAC;EACxF,IAAI;GACF,OAAQ,MAAM,SAAS,KAAK;EAC9B,QAAQ;GACN,OAAO,CAAC;EACV;EACA,IAAI,CAAC,SAAS,MAAM,KAAK,YAAY,MACnC,MAAM,IAAI,qBACR,SAAS,QACT,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAmD,CAAC,CACzF;EAIF,MAAM,KAAK,KAAK,QAAQ;EACxB,OAAO,OAAO,OAAO,WAAW,EAAE,mBAAmB,GAAG,IAAI,CAAC;CAC/D,EACF;AACF"}
@@ -1,5 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_email = require("../email.cjs");
3
+ const require_engine_retry = require("../engine/retry.cjs");
3
4
  const require_transports_sigv4 = require("./sigv4.cjs");
4
5
  //#region src/transports/ses.ts
5
6
  /** An SES error response: `type` is SES's error code (`MessageRejected`, `TooManyRequestsException`, ...). */
@@ -7,10 +8,13 @@ var SesSendError = class extends Error {
7
8
  status;
8
9
  type;
9
10
  name = "SesSendError";
11
+ /** A rejected request (an unverified sender, a bad address) fails the same way every time. */
12
+ retryable;
10
13
  constructor(status, type, message) {
11
14
  super(`SES SendEmail failed (${status}${type === void 0 ? "" : ` ${type}`}): ${message}`);
12
15
  this.status = status;
13
16
  this.type = type;
17
+ this.retryable = require_engine_retry.isRetryableStatus(status);
14
18
  }
15
19
  };
16
20
  function environmentCredentials() {
@@ -1 +1 @@
1
- {"version":3,"file":"ses.cjs","names":["mimeAddress","signRequest"],"sources":["../../src/transports/ses.ts"],"sourcesContent":["import { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { signRequest } from './sigv4';\n\n/**\n * `@shware/workflow/transports/ses` — email through Amazon SES (the v2 `SendEmail` API) over\n * plain `fetch` with a SigV4 signature, so the same transport runs in a Lambda and in a Worker.\n * Nothing here loads the AWS SDK or a Node-only credential chain.\n */\n\nexport interface AwsCredentials {\n accessKeyId: string;\n secretAccessKey: string;\n /** Present for temporary credentials (a role's): signed with the request. */\n sessionToken?: string | undefined;\n}\n\n/** Called on every send, so rotated temporary credentials are picked up rather than frozen. */\nexport type AwsCredentialProvider = () => AwsCredentials | Promise<AwsCredentials>;\n\nexport interface SesEmailTransportOptions {\n region: string;\n /**\n * Fixed credentials, or a provider read on every send. Defaults to the process environment's\n * `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, read at send time —\n * a Lambda's execution role. A Worker passes them explicitly from its validated env.\n */\n credentials?: AwsCredentials | AwsCredentialProvider;\n /** An SES configuration set (event publishing, a dedicated IP pool). */\n configurationSetName?: string;\n /** Defaults to `https://email.<region>.amazonaws.com`. */\n endpoint?: string;\n fetch?: typeof fetch;\n}\n\n/** An SES error response: `type` is SES's error code (`MessageRejected`, `TooManyRequestsException`, ...). */\nexport class SesSendError extends Error {\n override readonly name = 'SesSendError';\n constructor(\n readonly status: number,\n readonly type: string | undefined,\n message: string\n ) {\n super(`SES SendEmail failed (${status}${type === undefined ? '' : ` ${type}`}): ${message}`);\n }\n}\n\nfunction environmentCredentials(): AwsCredentials {\n // Read through globalThis: a Worker has no `process`, and must pass credentials instead\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process\n ?.env;\n const accessKeyId = env?.AWS_ACCESS_KEY_ID;\n const secretAccessKey = env?.AWS_SECRET_ACCESS_KEY;\n if (accessKeyId === undefined || secretAccessKey === undefined) {\n throw new Error(\n 'sesEmailTransport: no credentials — pass `credentials`, or run where AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are set (a Lambda execution role)'\n );\n }\n return { accessKeyId, secretAccessKey, sessionToken: env?.AWS_SESSION_TOKEN };\n}\n\nexport function sesEmailTransport(options: SesEmailTransportOptions): EmailTransport {\n const endpoint = (options.endpoint ?? `https://email.${options.region}.amazonaws.com`).replace(\n /\\/$/,\n ''\n );\n const credentials = async (): Promise<AwsCredentials> => {\n const configured = options.credentials;\n if (configured === undefined) return environmentCredentials();\n return typeof configured === 'function' ? configured() : configured;\n };\n const fetchImpl = options.fetch ?? fetch;\n\n return {\n async send(message: EmailMessage) {\n const body = JSON.stringify({\n FromEmailAddress: mimeAddress(message.from),\n Destination: { ToAddresses: [message.to] },\n ...(message.replyTo === undefined\n ? {}\n : { ReplyToAddresses: [mimeAddress(message.replyTo)] }),\n Content: {\n Simple: {\n Subject: { Data: message.subject, Charset: 'UTF-8' },\n Body: { Html: { Data: message.html, Charset: 'UTF-8' } },\n },\n },\n ...(options.configurationSetName === undefined\n ? {}\n : { ConfigurationSetName: options.configurationSetName }),\n });\n const url = `${endpoint}/v2/email/outbound-emails`;\n const headers = await signRequest({\n method: 'POST',\n url,\n headers: { 'content-type': 'application/json' },\n body,\n credentials: await credentials(),\n region: options.region,\n service: 'ses',\n });\n // No retries here: the durable step owns them, and counts them — SES cannot de-duplicate,\n // so every extra attempt of an accepted send is another chance to deliver twice\n const response = await fetchImpl(url, { method: 'POST', headers, body });\n const text = await response.text();\n if (!response.ok) {\n let detail = text;\n try {\n const parsed = JSON.parse(text) as { message?: unknown; Message?: unknown };\n const said = parsed.message ?? parsed.Message;\n if (typeof said === 'string') detail = said;\n } catch {\n // Not JSON: the raw body says what went wrong\n }\n const type = response.headers.get('x-amzn-errortype')?.split(':')[0];\n throw new SesSendError(response.status, type, detail);\n }\n let messageId: unknown;\n try {\n messageId = (JSON.parse(text) as { MessageId?: unknown }).MessageId;\n } catch {\n messageId = undefined;\n }\n return typeof messageId === 'string' ? { providerMessageId: messageId } : {};\n },\n };\n}\n"],"mappings":";;;;;AAoCA,IAAa,eAAb,cAAkC,MAAM;CAG3B;CACA;CAHX,OAAyB;CACzB,YACE,QACA,MACA,SACA;EACA,MAAM,yBAAyB,SAAS,SAAS,KAAA,IAAY,KAAK,IAAI,OAAO,KAAK,SAAS;EAJlF,KAAA,SAAA;EACA,KAAA,OAAA;CAIX;AACF;AAEA,SAAS,yBAAyC;CAEhD,MAAM,MAAO,WAA0E,SACnF;CACJ,MAAM,cAAc,KAAK;CACzB,MAAM,kBAAkB,KAAK;CAC7B,IAAI,gBAAgB,KAAA,KAAa,oBAAoB,KAAA,GACnD,MAAM,IAAI,MACR,oJACF;CAEF,OAAO;EAAE;EAAa;EAAiB,cAAc,KAAK;CAAkB;AAC9E;AAEA,SAAgB,kBAAkB,SAAmD;CACnF,MAAM,YAAY,QAAQ,YAAY,iBAAiB,QAAQ,OAAO,gBAAA,CAAiB,QACrF,OACA,EACF;CACA,MAAM,cAAc,YAAqC;EACvD,MAAM,aAAa,QAAQ;EAC3B,IAAI,eAAe,KAAA,GAAW,OAAO,uBAAuB;EAC5D,OAAO,OAAO,eAAe,aAAa,WAAW,IAAI;CAC3D;CACA,MAAM,YAAY,QAAQ,SAAS;CAEnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,OAAO,KAAK,UAAU;GAC1B,kBAAkBA,cAAAA,YAAY,QAAQ,IAAI;GAC1C,aAAa,EAAE,aAAa,CAAC,QAAQ,EAAE,EAAE;GACzC,GAAI,QAAQ,YAAY,KAAA,IACpB,CAAC,IACD,EAAE,kBAAkB,CAACA,cAAAA,YAAY,QAAQ,OAAO,CAAC,EAAE;GACvD,SAAS,EACP,QAAQ;IACN,SAAS;KAAE,MAAM,QAAQ;KAAS,SAAS;IAAQ;IACnD,MAAM,EAAE,MAAM;KAAE,MAAM,QAAQ;KAAM,SAAS;IAAQ,EAAE;GACzD,EACF;GACA,GAAI,QAAQ,yBAAyB,KAAA,IACjC,CAAC,IACD,EAAE,sBAAsB,QAAQ,qBAAqB;EAC3D,CAAC;EACD,MAAM,MAAM,GAAG,SAAS;EACxB,MAAM,UAAU,MAAMC,yBAAAA,YAAY;GAChC,QAAQ;GACR;GACA,SAAS,EAAE,gBAAgB,mBAAmB;GAC9C;GACA,aAAa,MAAM,YAAY;GAC/B,QAAQ,QAAQ;GAChB,SAAS;EACX,CAAC;EAGD,MAAM,WAAW,MAAM,UAAU,KAAK;GAAE,QAAQ;GAAQ;GAAS;EAAK,CAAC;EACvE,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,IAAI,CAAC,SAAS,IAAI;GAChB,IAAI,SAAS;GACb,IAAI;IACF,MAAM,SAAS,KAAK,MAAM,IAAI;IAC9B,MAAM,OAAO,OAAO,WAAW,OAAO;IACtC,IAAI,OAAO,SAAS,UAAU,SAAS;GACzC,QAAQ,CAER;GACA,MAAM,OAAO,SAAS,QAAQ,IAAI,kBAAkB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC;GAClE,MAAM,IAAI,aAAa,SAAS,QAAQ,MAAM,MAAM;EACtD;EACA,IAAI;EACJ,IAAI;GACF,YAAa,KAAK,MAAM,IAAI,CAAC,CAA6B;EAC5D,QAAQ;GACN,YAAY,KAAA;EACd;EACA,OAAO,OAAO,cAAc,WAAW,EAAE,mBAAmB,UAAU,IAAI,CAAC;CAC7E,EACF;AACF"}
1
+ {"version":3,"file":"ses.cjs","names":["isRetryableStatus","mimeAddress","signRequest"],"sources":["../../src/transports/ses.ts"],"sourcesContent":["import { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { isRetryableStatus } from '../engine/retry';\nimport { signRequest } from './sigv4';\n\n/**\n * `@shware/workflow/transports/ses` — email through Amazon SES (the v2 `SendEmail` API) over\n * plain `fetch` with a SigV4 signature, so the same transport runs in a Lambda and in a Worker.\n * Nothing here loads the AWS SDK or a Node-only credential chain.\n */\n\nexport interface AwsCredentials {\n accessKeyId: string;\n secretAccessKey: string;\n /** Present for temporary credentials (a role's): signed with the request. */\n sessionToken?: string | undefined;\n}\n\n/** Called on every send, so rotated temporary credentials are picked up rather than frozen. */\nexport type AwsCredentialProvider = () => AwsCredentials | Promise<AwsCredentials>;\n\nexport interface SesEmailTransportOptions {\n region: string;\n /**\n * Fixed credentials, or a provider read on every send. Defaults to the process environment's\n * `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, read at send time —\n * a Lambda's execution role. A Worker passes them explicitly from its validated env.\n */\n credentials?: AwsCredentials | AwsCredentialProvider;\n /** An SES configuration set (event publishing, a dedicated IP pool). */\n configurationSetName?: string;\n /** Defaults to `https://email.<region>.amazonaws.com`. */\n endpoint?: string;\n fetch?: typeof fetch;\n}\n\n/** An SES error response: `type` is SES's error code (`MessageRejected`, `TooManyRequestsException`, ...). */\nexport class SesSendError extends Error {\n override readonly name = 'SesSendError';\n /** A rejected request (an unverified sender, a bad address) fails the same way every time. */\n readonly retryable: boolean;\n constructor(\n readonly status: number,\n readonly type: string | undefined,\n message: string\n ) {\n super(`SES SendEmail failed (${status}${type === undefined ? '' : ` ${type}`}): ${message}`);\n this.retryable = isRetryableStatus(status);\n }\n}\n\nfunction environmentCredentials(): AwsCredentials {\n // Read through globalThis: a Worker has no `process`, and must pass credentials instead\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process\n ?.env;\n const accessKeyId = env?.AWS_ACCESS_KEY_ID;\n const secretAccessKey = env?.AWS_SECRET_ACCESS_KEY;\n if (accessKeyId === undefined || secretAccessKey === undefined) {\n throw new Error(\n 'sesEmailTransport: no credentials — pass `credentials`, or run where AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are set (a Lambda execution role)'\n );\n }\n return { accessKeyId, secretAccessKey, sessionToken: env?.AWS_SESSION_TOKEN };\n}\n\nexport function sesEmailTransport(options: SesEmailTransportOptions): EmailTransport {\n const endpoint = (options.endpoint ?? `https://email.${options.region}.amazonaws.com`).replace(\n /\\/$/,\n ''\n );\n const credentials = async (): Promise<AwsCredentials> => {\n const configured = options.credentials;\n if (configured === undefined) return environmentCredentials();\n return typeof configured === 'function' ? configured() : configured;\n };\n const fetchImpl = options.fetch ?? fetch;\n\n return {\n async send(message: EmailMessage) {\n const body = JSON.stringify({\n FromEmailAddress: mimeAddress(message.from),\n Destination: { ToAddresses: [message.to] },\n ...(message.replyTo === undefined\n ? {}\n : { ReplyToAddresses: [mimeAddress(message.replyTo)] }),\n Content: {\n Simple: {\n Subject: { Data: message.subject, Charset: 'UTF-8' },\n Body: { Html: { Data: message.html, Charset: 'UTF-8' } },\n },\n },\n ...(options.configurationSetName === undefined\n ? {}\n : { ConfigurationSetName: options.configurationSetName }),\n });\n const url = `${endpoint}/v2/email/outbound-emails`;\n const headers = await signRequest({\n method: 'POST',\n url,\n headers: { 'content-type': 'application/json' },\n body,\n credentials: await credentials(),\n region: options.region,\n service: 'ses',\n });\n // No retries here: the durable step owns them, and counts them — SES cannot de-duplicate,\n // so every extra attempt of an accepted send is another chance to deliver twice\n const response = await fetchImpl(url, { method: 'POST', headers, body });\n const text = await response.text();\n if (!response.ok) {\n let detail = text;\n try {\n const parsed = JSON.parse(text) as { message?: unknown; Message?: unknown };\n const said = parsed.message ?? parsed.Message;\n if (typeof said === 'string') detail = said;\n } catch {\n // Not JSON: the raw body says what went wrong\n }\n const type = response.headers.get('x-amzn-errortype')?.split(':')[0];\n throw new SesSendError(response.status, type, detail);\n }\n let messageId: unknown;\n try {\n messageId = (JSON.parse(text) as { MessageId?: unknown }).MessageId;\n } catch {\n messageId = undefined;\n }\n return typeof messageId === 'string' ? { providerMessageId: messageId } : {};\n },\n };\n}\n"],"mappings":";;;;;;AAqCA,IAAa,eAAb,cAAkC,MAAM;CAK3B;CACA;CALX,OAAyB;;CAEzB;CACA,YACE,QACA,MACA,SACA;EACA,MAAM,yBAAyB,SAAS,SAAS,KAAA,IAAY,KAAK,IAAI,OAAO,KAAK,SAAS;EAJlF,KAAA,SAAA;EACA,KAAA,OAAA;EAIT,KAAK,YAAYA,qBAAAA,kBAAkB,MAAM;CAC3C;AACF;AAEA,SAAS,yBAAyC;CAEhD,MAAM,MAAO,WAA0E,SACnF;CACJ,MAAM,cAAc,KAAK;CACzB,MAAM,kBAAkB,KAAK;CAC7B,IAAI,gBAAgB,KAAA,KAAa,oBAAoB,KAAA,GACnD,MAAM,IAAI,MACR,oJACF;CAEF,OAAO;EAAE;EAAa;EAAiB,cAAc,KAAK;CAAkB;AAC9E;AAEA,SAAgB,kBAAkB,SAAmD;CACnF,MAAM,YAAY,QAAQ,YAAY,iBAAiB,QAAQ,OAAO,gBAAA,CAAiB,QACrF,OACA,EACF;CACA,MAAM,cAAc,YAAqC;EACvD,MAAM,aAAa,QAAQ;EAC3B,IAAI,eAAe,KAAA,GAAW,OAAO,uBAAuB;EAC5D,OAAO,OAAO,eAAe,aAAa,WAAW,IAAI;CAC3D;CACA,MAAM,YAAY,QAAQ,SAAS;CAEnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,OAAO,KAAK,UAAU;GAC1B,kBAAkBC,cAAAA,YAAY,QAAQ,IAAI;GAC1C,aAAa,EAAE,aAAa,CAAC,QAAQ,EAAE,EAAE;GACzC,GAAI,QAAQ,YAAY,KAAA,IACpB,CAAC,IACD,EAAE,kBAAkB,CAACA,cAAAA,YAAY,QAAQ,OAAO,CAAC,EAAE;GACvD,SAAS,EACP,QAAQ;IACN,SAAS;KAAE,MAAM,QAAQ;KAAS,SAAS;IAAQ;IACnD,MAAM,EAAE,MAAM;KAAE,MAAM,QAAQ;KAAM,SAAS;IAAQ,EAAE;GACzD,EACF;GACA,GAAI,QAAQ,yBAAyB,KAAA,IACjC,CAAC,IACD,EAAE,sBAAsB,QAAQ,qBAAqB;EAC3D,CAAC;EACD,MAAM,MAAM,GAAG,SAAS;EACxB,MAAM,UAAU,MAAMC,yBAAAA,YAAY;GAChC,QAAQ;GACR;GACA,SAAS,EAAE,gBAAgB,mBAAmB;GAC9C;GACA,aAAa,MAAM,YAAY;GAC/B,QAAQ,QAAQ;GAChB,SAAS;EACX,CAAC;EAGD,MAAM,WAAW,MAAM,UAAU,KAAK;GAAE,QAAQ;GAAQ;GAAS;EAAK,CAAC;EACvE,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,IAAI,CAAC,SAAS,IAAI;GAChB,IAAI,SAAS;GACb,IAAI;IACF,MAAM,SAAS,KAAK,MAAM,IAAI;IAC9B,MAAM,OAAO,OAAO,WAAW,OAAO;IACtC,IAAI,OAAO,SAAS,UAAU,SAAS;GACzC,QAAQ,CAER;GACA,MAAM,OAAO,SAAS,QAAQ,IAAI,kBAAkB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC;GAClE,MAAM,IAAI,aAAa,SAAS,QAAQ,MAAM,MAAM;EACtD;EACA,IAAI;EACJ,IAAI;GACF,YAAa,KAAK,MAAM,IAAI,CAAC,CAA6B;EAC5D,QAAQ;GACN,YAAY,KAAA;EACd;EACA,OAAO,OAAO,cAAc,WAAW,EAAE,mBAAmB,UAAU,IAAI,CAAC;CAC7E,EACF;AACF"}
@@ -32,6 +32,8 @@ export declare class SesSendError extends Error {
32
32
  readonly status: number;
33
33
  readonly type: string | undefined;
34
34
  readonly name = "SesSendError";
35
+ /** A rejected request (an unverified sender, a bad address) fails the same way every time. */
36
+ readonly retryable: boolean;
35
37
  constructor(status: number, type: string | undefined, message: string);
36
38
  }
37
39
  export declare function sesEmailTransport(options: SesEmailTransportOptions): EmailTransport;
@@ -1 +1 @@
1
- {"version":3,"file":"ses.d.cts","names":[],"sources":["../../src/transports/ses.ts"],"mappings":";;;;;;;iBAUiB;EACf;EACA;;EAEA;;;YAIU,8BAA8B,iBAAiB,QAAQ;iBAElD;EACf;;;;;;EAMA,cAAc,iBAAiB;;EAE/B;;EAEA;EACA,eAAe;;;qBAIJ,qBAAqB;WAGrB;WACA;WAHO;EAClB,YACW,gBACA,0BACT;;wBAoBY,kBAAkB,SAAS,2BAA2B"}
1
+ {"version":3,"file":"ses.d.cts","names":[],"sources":["../../src/transports/ses.ts"],"mappings":";;;;;;;iBAWiB;EACf;EACA;;EAEA;;;YAIU,8BAA8B,iBAAiB,QAAQ;iBAElD;EACf;;;;;;EAMA,cAAc,iBAAiB;;EAE/B;;EAEA;EACA,eAAe;;;qBAIJ,qBAAqB;WAKrB;WACA;WALO;;WAET;EACT,YACW,gBACA,0BACT;;wBAqBY,kBAAkB,SAAS,2BAA2B"}
@@ -32,6 +32,8 @@ export declare class SesSendError extends Error {
32
32
  readonly status: number;
33
33
  readonly type: string | undefined;
34
34
  readonly name = "SesSendError";
35
+ /** A rejected request (an unverified sender, a bad address) fails the same way every time. */
36
+ readonly retryable: boolean;
35
37
  constructor(status: number, type: string | undefined, message: string);
36
38
  }
37
39
  export declare function sesEmailTransport(options: SesEmailTransportOptions): EmailTransport;
@@ -1 +1 @@
1
- {"version":3,"file":"ses.d.mts","names":[],"sources":["../../src/transports/ses.ts"],"mappings":";;;;;;;iBAUiB;EACf;EACA;;EAEA;;;YAIU,8BAA8B,iBAAiB,QAAQ;iBAElD;EACf;;;;;;EAMA,cAAc,iBAAiB;;EAE/B;;EAEA;EACA,eAAe;;;qBAIJ,qBAAqB;WAGrB;WACA;WAHO;EAClB,YACW,gBACA,0BACT;;wBAoBY,kBAAkB,SAAS,2BAA2B"}
1
+ {"version":3,"file":"ses.d.mts","names":[],"sources":["../../src/transports/ses.ts"],"mappings":";;;;;;;iBAWiB;EACf;EACA;;EAEA;;;YAIU,8BAA8B,iBAAiB,QAAQ;iBAElD;EACf;;;;;;EAMA,cAAc,iBAAiB;;EAE/B;;EAEA;EACA,eAAe;;;qBAIJ,qBAAqB;WAKrB;WACA;WALO;;WAET;EACT,YACW,gBACA,0BACT;;wBAqBY,kBAAkB,SAAS,2BAA2B"}
@@ -1,4 +1,5 @@
1
1
  import { mimeAddress } from "../email.mjs";
2
+ import { isRetryableStatus } from "../engine/retry.mjs";
2
3
  import { signRequest } from "./sigv4.mjs";
3
4
  //#region src/transports/ses.ts
4
5
  /** An SES error response: `type` is SES's error code (`MessageRejected`, `TooManyRequestsException`, ...). */
@@ -6,10 +7,13 @@ var SesSendError = class extends Error {
6
7
  status;
7
8
  type;
8
9
  name = "SesSendError";
10
+ /** A rejected request (an unverified sender, a bad address) fails the same way every time. */
11
+ retryable;
9
12
  constructor(status, type, message) {
10
13
  super(`SES SendEmail failed (${status}${type === void 0 ? "" : ` ${type}`}): ${message}`);
11
14
  this.status = status;
12
15
  this.type = type;
16
+ this.retryable = isRetryableStatus(status);
13
17
  }
14
18
  };
15
19
  function environmentCredentials() {
@@ -1 +1 @@
1
- {"version":3,"file":"ses.mjs","names":[],"sources":["../../src/transports/ses.ts"],"sourcesContent":["import { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { signRequest } from './sigv4';\n\n/**\n * `@shware/workflow/transports/ses` — email through Amazon SES (the v2 `SendEmail` API) over\n * plain `fetch` with a SigV4 signature, so the same transport runs in a Lambda and in a Worker.\n * Nothing here loads the AWS SDK or a Node-only credential chain.\n */\n\nexport interface AwsCredentials {\n accessKeyId: string;\n secretAccessKey: string;\n /** Present for temporary credentials (a role's): signed with the request. */\n sessionToken?: string | undefined;\n}\n\n/** Called on every send, so rotated temporary credentials are picked up rather than frozen. */\nexport type AwsCredentialProvider = () => AwsCredentials | Promise<AwsCredentials>;\n\nexport interface SesEmailTransportOptions {\n region: string;\n /**\n * Fixed credentials, or a provider read on every send. Defaults to the process environment's\n * `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, read at send time —\n * a Lambda's execution role. A Worker passes them explicitly from its validated env.\n */\n credentials?: AwsCredentials | AwsCredentialProvider;\n /** An SES configuration set (event publishing, a dedicated IP pool). */\n configurationSetName?: string;\n /** Defaults to `https://email.<region>.amazonaws.com`. */\n endpoint?: string;\n fetch?: typeof fetch;\n}\n\n/** An SES error response: `type` is SES's error code (`MessageRejected`, `TooManyRequestsException`, ...). */\nexport class SesSendError extends Error {\n override readonly name = 'SesSendError';\n constructor(\n readonly status: number,\n readonly type: string | undefined,\n message: string\n ) {\n super(`SES SendEmail failed (${status}${type === undefined ? '' : ` ${type}`}): ${message}`);\n }\n}\n\nfunction environmentCredentials(): AwsCredentials {\n // Read through globalThis: a Worker has no `process`, and must pass credentials instead\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process\n ?.env;\n const accessKeyId = env?.AWS_ACCESS_KEY_ID;\n const secretAccessKey = env?.AWS_SECRET_ACCESS_KEY;\n if (accessKeyId === undefined || secretAccessKey === undefined) {\n throw new Error(\n 'sesEmailTransport: no credentials — pass `credentials`, or run where AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are set (a Lambda execution role)'\n );\n }\n return { accessKeyId, secretAccessKey, sessionToken: env?.AWS_SESSION_TOKEN };\n}\n\nexport function sesEmailTransport(options: SesEmailTransportOptions): EmailTransport {\n const endpoint = (options.endpoint ?? `https://email.${options.region}.amazonaws.com`).replace(\n /\\/$/,\n ''\n );\n const credentials = async (): Promise<AwsCredentials> => {\n const configured = options.credentials;\n if (configured === undefined) return environmentCredentials();\n return typeof configured === 'function' ? configured() : configured;\n };\n const fetchImpl = options.fetch ?? fetch;\n\n return {\n async send(message: EmailMessage) {\n const body = JSON.stringify({\n FromEmailAddress: mimeAddress(message.from),\n Destination: { ToAddresses: [message.to] },\n ...(message.replyTo === undefined\n ? {}\n : { ReplyToAddresses: [mimeAddress(message.replyTo)] }),\n Content: {\n Simple: {\n Subject: { Data: message.subject, Charset: 'UTF-8' },\n Body: { Html: { Data: message.html, Charset: 'UTF-8' } },\n },\n },\n ...(options.configurationSetName === undefined\n ? {}\n : { ConfigurationSetName: options.configurationSetName }),\n });\n const url = `${endpoint}/v2/email/outbound-emails`;\n const headers = await signRequest({\n method: 'POST',\n url,\n headers: { 'content-type': 'application/json' },\n body,\n credentials: await credentials(),\n region: options.region,\n service: 'ses',\n });\n // No retries here: the durable step owns them, and counts them — SES cannot de-duplicate,\n // so every extra attempt of an accepted send is another chance to deliver twice\n const response = await fetchImpl(url, { method: 'POST', headers, body });\n const text = await response.text();\n if (!response.ok) {\n let detail = text;\n try {\n const parsed = JSON.parse(text) as { message?: unknown; Message?: unknown };\n const said = parsed.message ?? parsed.Message;\n if (typeof said === 'string') detail = said;\n } catch {\n // Not JSON: the raw body says what went wrong\n }\n const type = response.headers.get('x-amzn-errortype')?.split(':')[0];\n throw new SesSendError(response.status, type, detail);\n }\n let messageId: unknown;\n try {\n messageId = (JSON.parse(text) as { MessageId?: unknown }).MessageId;\n } catch {\n messageId = undefined;\n }\n return typeof messageId === 'string' ? { providerMessageId: messageId } : {};\n },\n };\n}\n"],"mappings":";;;;AAoCA,IAAa,eAAb,cAAkC,MAAM;CAG3B;CACA;CAHX,OAAyB;CACzB,YACE,QACA,MACA,SACA;EACA,MAAM,yBAAyB,SAAS,SAAS,KAAA,IAAY,KAAK,IAAI,OAAO,KAAK,SAAS;EAJlF,KAAA,SAAA;EACA,KAAA,OAAA;CAIX;AACF;AAEA,SAAS,yBAAyC;CAEhD,MAAM,MAAO,WAA0E,SACnF;CACJ,MAAM,cAAc,KAAK;CACzB,MAAM,kBAAkB,KAAK;CAC7B,IAAI,gBAAgB,KAAA,KAAa,oBAAoB,KAAA,GACnD,MAAM,IAAI,MACR,oJACF;CAEF,OAAO;EAAE;EAAa;EAAiB,cAAc,KAAK;CAAkB;AAC9E;AAEA,SAAgB,kBAAkB,SAAmD;CACnF,MAAM,YAAY,QAAQ,YAAY,iBAAiB,QAAQ,OAAO,gBAAA,CAAiB,QACrF,OACA,EACF;CACA,MAAM,cAAc,YAAqC;EACvD,MAAM,aAAa,QAAQ;EAC3B,IAAI,eAAe,KAAA,GAAW,OAAO,uBAAuB;EAC5D,OAAO,OAAO,eAAe,aAAa,WAAW,IAAI;CAC3D;CACA,MAAM,YAAY,QAAQ,SAAS;CAEnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,OAAO,KAAK,UAAU;GAC1B,kBAAkB,YAAY,QAAQ,IAAI;GAC1C,aAAa,EAAE,aAAa,CAAC,QAAQ,EAAE,EAAE;GACzC,GAAI,QAAQ,YAAY,KAAA,IACpB,CAAC,IACD,EAAE,kBAAkB,CAAC,YAAY,QAAQ,OAAO,CAAC,EAAE;GACvD,SAAS,EACP,QAAQ;IACN,SAAS;KAAE,MAAM,QAAQ;KAAS,SAAS;IAAQ;IACnD,MAAM,EAAE,MAAM;KAAE,MAAM,QAAQ;KAAM,SAAS;IAAQ,EAAE;GACzD,EACF;GACA,GAAI,QAAQ,yBAAyB,KAAA,IACjC,CAAC,IACD,EAAE,sBAAsB,QAAQ,qBAAqB;EAC3D,CAAC;EACD,MAAM,MAAM,GAAG,SAAS;EACxB,MAAM,UAAU,MAAM,YAAY;GAChC,QAAQ;GACR;GACA,SAAS,EAAE,gBAAgB,mBAAmB;GAC9C;GACA,aAAa,MAAM,YAAY;GAC/B,QAAQ,QAAQ;GAChB,SAAS;EACX,CAAC;EAGD,MAAM,WAAW,MAAM,UAAU,KAAK;GAAE,QAAQ;GAAQ;GAAS;EAAK,CAAC;EACvE,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,IAAI,CAAC,SAAS,IAAI;GAChB,IAAI,SAAS;GACb,IAAI;IACF,MAAM,SAAS,KAAK,MAAM,IAAI;IAC9B,MAAM,OAAO,OAAO,WAAW,OAAO;IACtC,IAAI,OAAO,SAAS,UAAU,SAAS;GACzC,QAAQ,CAER;GACA,MAAM,OAAO,SAAS,QAAQ,IAAI,kBAAkB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC;GAClE,MAAM,IAAI,aAAa,SAAS,QAAQ,MAAM,MAAM;EACtD;EACA,IAAI;EACJ,IAAI;GACF,YAAa,KAAK,MAAM,IAAI,CAAC,CAA6B;EAC5D,QAAQ;GACN,YAAY,KAAA;EACd;EACA,OAAO,OAAO,cAAc,WAAW,EAAE,mBAAmB,UAAU,IAAI,CAAC;CAC7E,EACF;AACF"}
1
+ {"version":3,"file":"ses.mjs","names":[],"sources":["../../src/transports/ses.ts"],"sourcesContent":["import { mimeAddress } from '../email';\nimport type { EmailMessage, EmailTransport } from '../email-sender';\nimport { isRetryableStatus } from '../engine/retry';\nimport { signRequest } from './sigv4';\n\n/**\n * `@shware/workflow/transports/ses` — email through Amazon SES (the v2 `SendEmail` API) over\n * plain `fetch` with a SigV4 signature, so the same transport runs in a Lambda and in a Worker.\n * Nothing here loads the AWS SDK or a Node-only credential chain.\n */\n\nexport interface AwsCredentials {\n accessKeyId: string;\n secretAccessKey: string;\n /** Present for temporary credentials (a role's): signed with the request. */\n sessionToken?: string | undefined;\n}\n\n/** Called on every send, so rotated temporary credentials are picked up rather than frozen. */\nexport type AwsCredentialProvider = () => AwsCredentials | Promise<AwsCredentials>;\n\nexport interface SesEmailTransportOptions {\n region: string;\n /**\n * Fixed credentials, or a provider read on every send. Defaults to the process environment's\n * `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, read at send time —\n * a Lambda's execution role. A Worker passes them explicitly from its validated env.\n */\n credentials?: AwsCredentials | AwsCredentialProvider;\n /** An SES configuration set (event publishing, a dedicated IP pool). */\n configurationSetName?: string;\n /** Defaults to `https://email.<region>.amazonaws.com`. */\n endpoint?: string;\n fetch?: typeof fetch;\n}\n\n/** An SES error response: `type` is SES's error code (`MessageRejected`, `TooManyRequestsException`, ...). */\nexport class SesSendError extends Error {\n override readonly name = 'SesSendError';\n /** A rejected request (an unverified sender, a bad address) fails the same way every time. */\n readonly retryable: boolean;\n constructor(\n readonly status: number,\n readonly type: string | undefined,\n message: string\n ) {\n super(`SES SendEmail failed (${status}${type === undefined ? '' : ` ${type}`}): ${message}`);\n this.retryable = isRetryableStatus(status);\n }\n}\n\nfunction environmentCredentials(): AwsCredentials {\n // Read through globalThis: a Worker has no `process`, and must pass credentials instead\n const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process\n ?.env;\n const accessKeyId = env?.AWS_ACCESS_KEY_ID;\n const secretAccessKey = env?.AWS_SECRET_ACCESS_KEY;\n if (accessKeyId === undefined || secretAccessKey === undefined) {\n throw new Error(\n 'sesEmailTransport: no credentials — pass `credentials`, or run where AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are set (a Lambda execution role)'\n );\n }\n return { accessKeyId, secretAccessKey, sessionToken: env?.AWS_SESSION_TOKEN };\n}\n\nexport function sesEmailTransport(options: SesEmailTransportOptions): EmailTransport {\n const endpoint = (options.endpoint ?? `https://email.${options.region}.amazonaws.com`).replace(\n /\\/$/,\n ''\n );\n const credentials = async (): Promise<AwsCredentials> => {\n const configured = options.credentials;\n if (configured === undefined) return environmentCredentials();\n return typeof configured === 'function' ? configured() : configured;\n };\n const fetchImpl = options.fetch ?? fetch;\n\n return {\n async send(message: EmailMessage) {\n const body = JSON.stringify({\n FromEmailAddress: mimeAddress(message.from),\n Destination: { ToAddresses: [message.to] },\n ...(message.replyTo === undefined\n ? {}\n : { ReplyToAddresses: [mimeAddress(message.replyTo)] }),\n Content: {\n Simple: {\n Subject: { Data: message.subject, Charset: 'UTF-8' },\n Body: { Html: { Data: message.html, Charset: 'UTF-8' } },\n },\n },\n ...(options.configurationSetName === undefined\n ? {}\n : { ConfigurationSetName: options.configurationSetName }),\n });\n const url = `${endpoint}/v2/email/outbound-emails`;\n const headers = await signRequest({\n method: 'POST',\n url,\n headers: { 'content-type': 'application/json' },\n body,\n credentials: await credentials(),\n region: options.region,\n service: 'ses',\n });\n // No retries here: the durable step owns them, and counts them — SES cannot de-duplicate,\n // so every extra attempt of an accepted send is another chance to deliver twice\n const response = await fetchImpl(url, { method: 'POST', headers, body });\n const text = await response.text();\n if (!response.ok) {\n let detail = text;\n try {\n const parsed = JSON.parse(text) as { message?: unknown; Message?: unknown };\n const said = parsed.message ?? parsed.Message;\n if (typeof said === 'string') detail = said;\n } catch {\n // Not JSON: the raw body says what went wrong\n }\n const type = response.headers.get('x-amzn-errortype')?.split(':')[0];\n throw new SesSendError(response.status, type, detail);\n }\n let messageId: unknown;\n try {\n messageId = (JSON.parse(text) as { MessageId?: unknown }).MessageId;\n } catch {\n messageId = undefined;\n }\n return typeof messageId === 'string' ? { providerMessageId: messageId } : {};\n },\n };\n}\n"],"mappings":";;;;;AAqCA,IAAa,eAAb,cAAkC,MAAM;CAK3B;CACA;CALX,OAAyB;;CAEzB;CACA,YACE,QACA,MACA,SACA;EACA,MAAM,yBAAyB,SAAS,SAAS,KAAA,IAAY,KAAK,IAAI,OAAO,KAAK,SAAS;EAJlF,KAAA,SAAA;EACA,KAAA,OAAA;EAIT,KAAK,YAAY,kBAAkB,MAAM;CAC3C;AACF;AAEA,SAAS,yBAAyC;CAEhD,MAAM,MAAO,WAA0E,SACnF;CACJ,MAAM,cAAc,KAAK;CACzB,MAAM,kBAAkB,KAAK;CAC7B,IAAI,gBAAgB,KAAA,KAAa,oBAAoB,KAAA,GACnD,MAAM,IAAI,MACR,oJACF;CAEF,OAAO;EAAE;EAAa;EAAiB,cAAc,KAAK;CAAkB;AAC9E;AAEA,SAAgB,kBAAkB,SAAmD;CACnF,MAAM,YAAY,QAAQ,YAAY,iBAAiB,QAAQ,OAAO,gBAAA,CAAiB,QACrF,OACA,EACF;CACA,MAAM,cAAc,YAAqC;EACvD,MAAM,aAAa,QAAQ;EAC3B,IAAI,eAAe,KAAA,GAAW,OAAO,uBAAuB;EAC5D,OAAO,OAAO,eAAe,aAAa,WAAW,IAAI;CAC3D;CACA,MAAM,YAAY,QAAQ,SAAS;CAEnC,OAAO,EACL,MAAM,KAAK,SAAuB;EAChC,MAAM,OAAO,KAAK,UAAU;GAC1B,kBAAkB,YAAY,QAAQ,IAAI;GAC1C,aAAa,EAAE,aAAa,CAAC,QAAQ,EAAE,EAAE;GACzC,GAAI,QAAQ,YAAY,KAAA,IACpB,CAAC,IACD,EAAE,kBAAkB,CAAC,YAAY,QAAQ,OAAO,CAAC,EAAE;GACvD,SAAS,EACP,QAAQ;IACN,SAAS;KAAE,MAAM,QAAQ;KAAS,SAAS;IAAQ;IACnD,MAAM,EAAE,MAAM;KAAE,MAAM,QAAQ;KAAM,SAAS;IAAQ,EAAE;GACzD,EACF;GACA,GAAI,QAAQ,yBAAyB,KAAA,IACjC,CAAC,IACD,EAAE,sBAAsB,QAAQ,qBAAqB;EAC3D,CAAC;EACD,MAAM,MAAM,GAAG,SAAS;EACxB,MAAM,UAAU,MAAM,YAAY;GAChC,QAAQ;GACR;GACA,SAAS,EAAE,gBAAgB,mBAAmB;GAC9C;GACA,aAAa,MAAM,YAAY;GAC/B,QAAQ,QAAQ;GAChB,SAAS;EACX,CAAC;EAGD,MAAM,WAAW,MAAM,UAAU,KAAK;GAAE,QAAQ;GAAQ;GAAS;EAAK,CAAC;EACvE,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,IAAI,CAAC,SAAS,IAAI;GAChB,IAAI,SAAS;GACb,IAAI;IACF,MAAM,SAAS,KAAK,MAAM,IAAI;IAC9B,MAAM,OAAO,OAAO,WAAW,OAAO;IACtC,IAAI,OAAO,SAAS,UAAU,SAAS;GACzC,QAAQ,CAER;GACA,MAAM,OAAO,SAAS,QAAQ,IAAI,kBAAkB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC;GAClE,MAAM,IAAI,aAAa,SAAS,QAAQ,MAAM,MAAM;EACtD;EACA,IAAI;EACJ,IAAI;GACF,YAAa,KAAK,MAAM,IAAI,CAAC,CAA6B;EAC5D,QAAQ;GACN,YAAY,KAAA;EACd;EACA,OAAO,OAAO,cAAc,WAAW,EAAE,mBAAmB,UAAU,IAAI,CAAC;CAC7E,EACF;AACF"}
@@ -18,7 +18,12 @@ async function hmac(key, data) {
18
18
  function encode(value) {
19
19
  return encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
20
20
  }
21
- /** Each path segment encoded once (the URL already decoded it). */
21
+ /**
22
+ * Each path segment encoded once (the URL already decoded it). SigV4 asks non-S3 services to
23
+ * encode the path twice; for the fixed, plain paths signed here (SES's
24
+ * `/v2/email/outbound-emails`) the two agree. A path with reserved characters would need the
25
+ * second pass before this signer could be used for it.
26
+ */
22
27
  function canonicalPath(pathname) {
23
28
  return pathname.split("/").map((segment) => encode(decodeURIComponent(segment))).join("/");
24
29
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sigv4.cjs","names":[],"sources":["../../src/transports/sigv4.ts"],"sourcesContent":["/**\n * AWS Signature Version 4 over WebCrypto, which a Worker and Node both have: enough to sign\n * the JSON API calls the transports make. Headers are signed as given (plus `host` and\n * `x-amz-date`); the payload hash is the body's SHA-256.\n * https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html\n */\n\nexport interface SigV4Credentials {\n accessKeyId: string;\n secretAccessKey: string;\n sessionToken?: string | undefined;\n}\n\nexport interface SigV4Request {\n method: string;\n url: string;\n headers?: Record<string, string>;\n body?: string;\n credentials: SigV4Credentials;\n region: string;\n service: string;\n /** `YYYYMMDD'T'HHMMSS'Z'`; now by default. */\n datetime?: string;\n}\n\nconst utf8 = new TextEncoder();\n\nfunction hex(bytes: ArrayBuffer): string {\n return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join('');\n}\n\nasync function sha256(data: string): Promise<string> {\n return hex(await crypto.subtle.digest('SHA-256', utf8.encode(data)));\n}\n\nasync function hmac(\n key: ArrayBuffer | Uint8Array<ArrayBuffer>,\n data: string\n): Promise<ArrayBuffer> {\n const imported = await crypto.subtle.importKey(\n 'raw',\n key,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign']\n );\n return crypto.subtle.sign('HMAC', imported, utf8.encode(data));\n}\n\n/** RFC 3986 encoding, as SigV4 wants it: only unreserved characters stay. */\nfunction encode(value: string): string {\n return encodeURIComponent(value).replace(\n /[!'()*]/g,\n (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`\n );\n}\n\n/** Each path segment encoded once (the URL already decoded it). */\nfunction canonicalPath(pathname: string): string {\n return pathname\n .split('/')\n .map((segment) => encode(decodeURIComponent(segment)))\n .join('/');\n}\n\n/**\n * The query as sent, each name and value re-encoded. Parsed by hand: URLSearchParams reads `+`\n * as a space, which would sign a value other than the one on the wire.\n */\nfunction canonicalQuery(search: string): string {\n if (search === '' || search === '?') return '';\n return search\n .slice(1)\n .split('&')\n .filter((pair) => pair !== '')\n .map((pair) => {\n const at = pair.indexOf('=');\n const name = at === -1 ? pair : pair.slice(0, at);\n const value = at === -1 ? '' : pair.slice(at + 1);\n return [encode(decodeURIComponent(name)), encode(decodeURIComponent(value))] as const;\n })\n .sort(([a, av], [b, bv]) => (a === b ? (av < bv ? -1 : av > bv ? 1 : 0) : a < b ? -1 : 1))\n .map(([name, value]) => `${name}=${value}`)\n .join('&');\n}\n\n/** The request's headers with `authorization` (and `x-amz-date`, the session token) added. */\nexport async function signRequest(request: SigV4Request): Promise<Record<string, string>> {\n const url = new URL(request.url);\n const datetime = request.datetime ?? new Date().toISOString().replace(/[:-]|\\.\\d{3}/g, '');\n const date = datetime.slice(0, 8);\n const { accessKeyId, secretAccessKey, sessionToken } = request.credentials;\n\n const headers: Record<string, string> = {};\n for (const [name, value] of Object.entries(request.headers ?? {})) {\n headers[name.toLowerCase()] = value.trim().replace(/\\s+/g, ' ');\n }\n headers.host = url.host;\n headers['x-amz-date'] = datetime;\n if (sessionToken !== undefined) headers['x-amz-security-token'] = sessionToken;\n\n const names = Object.keys(headers).sort();\n const signedHeaders = names.join(';');\n const canonical = [\n request.method.toUpperCase(),\n canonicalPath(url.pathname),\n canonicalQuery(url.search),\n ...names.map((name) => `${name}:${headers[name]}`),\n '',\n signedHeaders,\n await sha256(request.body ?? ''),\n ].join('\\n');\n\n const scope = `${date}/${request.region}/${request.service}/aws4_request`;\n const toSign = ['AWS4-HMAC-SHA256', datetime, scope, await sha256(canonical)].join('\\n');\n\n let key = await hmac(utf8.encode(`AWS4${secretAccessKey}`), date);\n for (const part of [request.region, request.service, 'aws4_request']) {\n key = await hmac(key, part);\n }\n const signature = hex(await hmac(key, toSign));\n\n const { host: _host, ...sent } = headers;\n return {\n ...sent,\n authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sigv4.cjs","names":[],"sources":["../../src/transports/sigv4.ts"],"sourcesContent":["/**\n * AWS Signature Version 4 over WebCrypto, which a Worker and Node both have: enough to sign\n * the JSON API calls the transports make. Headers are signed as given (plus `host` and\n * `x-amz-date`); the payload hash is the body's SHA-256.\n * https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html\n */\n\nexport interface SigV4Credentials {\n accessKeyId: string;\n secretAccessKey: string;\n sessionToken?: string | undefined;\n}\n\nexport interface SigV4Request {\n method: string;\n url: string;\n headers?: Record<string, string>;\n body?: string;\n credentials: SigV4Credentials;\n region: string;\n service: string;\n /** `YYYYMMDD'T'HHMMSS'Z'`; now by default. */\n datetime?: string;\n}\n\nconst utf8 = new TextEncoder();\n\nfunction hex(bytes: ArrayBuffer): string {\n return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join('');\n}\n\nasync function sha256(data: string): Promise<string> {\n return hex(await crypto.subtle.digest('SHA-256', utf8.encode(data)));\n}\n\nasync function hmac(\n key: ArrayBuffer | Uint8Array<ArrayBuffer>,\n data: string\n): Promise<ArrayBuffer> {\n const imported = await crypto.subtle.importKey(\n 'raw',\n key,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign']\n );\n return crypto.subtle.sign('HMAC', imported, utf8.encode(data));\n}\n\n/** RFC 3986 encoding, as SigV4 wants it: only unreserved characters stay. */\nfunction encode(value: string): string {\n return encodeURIComponent(value).replace(\n /[!'()*]/g,\n (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`\n );\n}\n\n/**\n * Each path segment encoded once (the URL already decoded it). SigV4 asks non-S3 services to\n * encode the path twice; for the fixed, plain paths signed here (SES's\n * `/v2/email/outbound-emails`) the two agree. A path with reserved characters would need the\n * second pass before this signer could be used for it.\n */\nfunction canonicalPath(pathname: string): string {\n return pathname\n .split('/')\n .map((segment) => encode(decodeURIComponent(segment)))\n .join('/');\n}\n\n/**\n * The query as sent, each name and value re-encoded. Parsed by hand: URLSearchParams reads `+`\n * as a space, which would sign a value other than the one on the wire.\n */\nfunction canonicalQuery(search: string): string {\n if (search === '' || search === '?') return '';\n return search\n .slice(1)\n .split('&')\n .filter((pair) => pair !== '')\n .map((pair) => {\n const at = pair.indexOf('=');\n const name = at === -1 ? pair : pair.slice(0, at);\n const value = at === -1 ? '' : pair.slice(at + 1);\n return [encode(decodeURIComponent(name)), encode(decodeURIComponent(value))] as const;\n })\n .sort(([a, av], [b, bv]) => (a === b ? (av < bv ? -1 : av > bv ? 1 : 0) : a < b ? -1 : 1))\n .map(([name, value]) => `${name}=${value}`)\n .join('&');\n}\n\n/** The request's headers with `authorization` (and `x-amz-date`, the session token) added. */\nexport async function signRequest(request: SigV4Request): Promise<Record<string, string>> {\n const url = new URL(request.url);\n const datetime = request.datetime ?? new Date().toISOString().replace(/[:-]|\\.\\d{3}/g, '');\n const date = datetime.slice(0, 8);\n const { accessKeyId, secretAccessKey, sessionToken } = request.credentials;\n\n const headers: Record<string, string> = {};\n for (const [name, value] of Object.entries(request.headers ?? {})) {\n headers[name.toLowerCase()] = value.trim().replace(/\\s+/g, ' ');\n }\n headers.host = url.host;\n headers['x-amz-date'] = datetime;\n if (sessionToken !== undefined) headers['x-amz-security-token'] = sessionToken;\n\n const names = Object.keys(headers).sort();\n const signedHeaders = names.join(';');\n const canonical = [\n request.method.toUpperCase(),\n canonicalPath(url.pathname),\n canonicalQuery(url.search),\n ...names.map((name) => `${name}:${headers[name]}`),\n '',\n signedHeaders,\n await sha256(request.body ?? ''),\n ].join('\\n');\n\n const scope = `${date}/${request.region}/${request.service}/aws4_request`;\n const toSign = ['AWS4-HMAC-SHA256', datetime, scope, await sha256(canonical)].join('\\n');\n\n let key = await hmac(utf8.encode(`AWS4${secretAccessKey}`), date);\n for (const part of [request.region, request.service, 'aws4_request']) {\n key = await hmac(key, part);\n }\n const signature = hex(await hmac(key, toSign));\n\n const { host: _host, ...sent } = headers;\n return {\n ...sent,\n authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,\n };\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"sigv4.d.cts","names":[],"sources":["../../src/transports/sigv4.ts"],"mappings":";;;;;;;iBAOiB;EACf;EACA;EACA;;iBAGe;EACf;EACA;EACA,UAAU;EACV;EACA,aAAa;EACb;EACA;;EAEA;;;wBAiEoB,YAAY,SAAS,eAAe,QAAQ"}
1
+ {"version":3,"file":"sigv4.d.cts","names":[],"sources":["../../src/transports/sigv4.ts"],"mappings":";;;;;;;iBAOiB;EACf;EACA;EACA;;iBAGe;EACf;EACA;EACA,UAAU;EACV;EACA,aAAa;EACb;EACA;;EAEA;;;wBAsEoB,YAAY,SAAS,eAAe,QAAQ"}
@@ -1 +1 @@
1
- {"version":3,"file":"sigv4.d.mts","names":[],"sources":["../../src/transports/sigv4.ts"],"mappings":";;;;;;;iBAOiB;EACf;EACA;EACA;;iBAGe;EACf;EACA;EACA,UAAU;EACV;EACA,aAAa;EACb;EACA;;EAEA;;;wBAiEoB,YAAY,SAAS,eAAe,QAAQ"}
1
+ {"version":3,"file":"sigv4.d.mts","names":[],"sources":["../../src/transports/sigv4.ts"],"mappings":";;;;;;;iBAOiB;EACf;EACA;EACA;;iBAGe;EACf;EACA;EACA,UAAU;EACV;EACA,aAAa;EACb;EACA;;EAEA;;;wBAsEoB,YAAY,SAAS,eAAe,QAAQ"}
@@ -17,7 +17,12 @@ async function hmac(key, data) {
17
17
  function encode(value) {
18
18
  return encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
19
19
  }
20
- /** Each path segment encoded once (the URL already decoded it). */
20
+ /**
21
+ * Each path segment encoded once (the URL already decoded it). SigV4 asks non-S3 services to
22
+ * encode the path twice; for the fixed, plain paths signed here (SES's
23
+ * `/v2/email/outbound-emails`) the two agree. A path with reserved characters would need the
24
+ * second pass before this signer could be used for it.
25
+ */
21
26
  function canonicalPath(pathname) {
22
27
  return pathname.split("/").map((segment) => encode(decodeURIComponent(segment))).join("/");
23
28
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sigv4.mjs","names":[],"sources":["../../src/transports/sigv4.ts"],"sourcesContent":["/**\n * AWS Signature Version 4 over WebCrypto, which a Worker and Node both have: enough to sign\n * the JSON API calls the transports make. Headers are signed as given (plus `host` and\n * `x-amz-date`); the payload hash is the body's SHA-256.\n * https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html\n */\n\nexport interface SigV4Credentials {\n accessKeyId: string;\n secretAccessKey: string;\n sessionToken?: string | undefined;\n}\n\nexport interface SigV4Request {\n method: string;\n url: string;\n headers?: Record<string, string>;\n body?: string;\n credentials: SigV4Credentials;\n region: string;\n service: string;\n /** `YYYYMMDD'T'HHMMSS'Z'`; now by default. */\n datetime?: string;\n}\n\nconst utf8 = new TextEncoder();\n\nfunction hex(bytes: ArrayBuffer): string {\n return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join('');\n}\n\nasync function sha256(data: string): Promise<string> {\n return hex(await crypto.subtle.digest('SHA-256', utf8.encode(data)));\n}\n\nasync function hmac(\n key: ArrayBuffer | Uint8Array<ArrayBuffer>,\n data: string\n): Promise<ArrayBuffer> {\n const imported = await crypto.subtle.importKey(\n 'raw',\n key,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign']\n );\n return crypto.subtle.sign('HMAC', imported, utf8.encode(data));\n}\n\n/** RFC 3986 encoding, as SigV4 wants it: only unreserved characters stay. */\nfunction encode(value: string): string {\n return encodeURIComponent(value).replace(\n /[!'()*]/g,\n (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`\n );\n}\n\n/** Each path segment encoded once (the URL already decoded it). */\nfunction canonicalPath(pathname: string): string {\n return pathname\n .split('/')\n .map((segment) => encode(decodeURIComponent(segment)))\n .join('/');\n}\n\n/**\n * The query as sent, each name and value re-encoded. Parsed by hand: URLSearchParams reads `+`\n * as a space, which would sign a value other than the one on the wire.\n */\nfunction canonicalQuery(search: string): string {\n if (search === '' || search === '?') return '';\n return search\n .slice(1)\n .split('&')\n .filter((pair) => pair !== '')\n .map((pair) => {\n const at = pair.indexOf('=');\n const name = at === -1 ? pair : pair.slice(0, at);\n const value = at === -1 ? '' : pair.slice(at + 1);\n return [encode(decodeURIComponent(name)), encode(decodeURIComponent(value))] as const;\n })\n .sort(([a, av], [b, bv]) => (a === b ? (av < bv ? -1 : av > bv ? 1 : 0) : a < b ? -1 : 1))\n .map(([name, value]) => `${name}=${value}`)\n .join('&');\n}\n\n/** The request's headers with `authorization` (and `x-amz-date`, the session token) added. */\nexport async function signRequest(request: SigV4Request): Promise<Record<string, string>> {\n const url = new URL(request.url);\n const datetime = request.datetime ?? new Date().toISOString().replace(/[:-]|\\.\\d{3}/g, '');\n const date = datetime.slice(0, 8);\n const { accessKeyId, secretAccessKey, sessionToken } = request.credentials;\n\n const headers: Record<string, string> = {};\n for (const [name, value] of Object.entries(request.headers ?? {})) {\n headers[name.toLowerCase()] = value.trim().replace(/\\s+/g, ' ');\n }\n headers.host = url.host;\n headers['x-amz-date'] = datetime;\n if (sessionToken !== undefined) headers['x-amz-security-token'] = sessionToken;\n\n const names = Object.keys(headers).sort();\n const signedHeaders = names.join(';');\n const canonical = [\n request.method.toUpperCase(),\n canonicalPath(url.pathname),\n canonicalQuery(url.search),\n ...names.map((name) => `${name}:${headers[name]}`),\n '',\n signedHeaders,\n await sha256(request.body ?? ''),\n ].join('\\n');\n\n const scope = `${date}/${request.region}/${request.service}/aws4_request`;\n const toSign = ['AWS4-HMAC-SHA256', datetime, scope, await sha256(canonical)].join('\\n');\n\n let key = await hmac(utf8.encode(`AWS4${secretAccessKey}`), date);\n for (const part of [request.region, request.service, 'aws4_request']) {\n key = await hmac(key, part);\n }\n const signature = hex(await hmac(key, toSign));\n\n const { host: _host, ...sent } = headers;\n return {\n ...sent,\n authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sigv4.mjs","names":[],"sources":["../../src/transports/sigv4.ts"],"sourcesContent":["/**\n * AWS Signature Version 4 over WebCrypto, which a Worker and Node both have: enough to sign\n * the JSON API calls the transports make. Headers are signed as given (plus `host` and\n * `x-amz-date`); the payload hash is the body's SHA-256.\n * https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html\n */\n\nexport interface SigV4Credentials {\n accessKeyId: string;\n secretAccessKey: string;\n sessionToken?: string | undefined;\n}\n\nexport interface SigV4Request {\n method: string;\n url: string;\n headers?: Record<string, string>;\n body?: string;\n credentials: SigV4Credentials;\n region: string;\n service: string;\n /** `YYYYMMDD'T'HHMMSS'Z'`; now by default. */\n datetime?: string;\n}\n\nconst utf8 = new TextEncoder();\n\nfunction hex(bytes: ArrayBuffer): string {\n return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join('');\n}\n\nasync function sha256(data: string): Promise<string> {\n return hex(await crypto.subtle.digest('SHA-256', utf8.encode(data)));\n}\n\nasync function hmac(\n key: ArrayBuffer | Uint8Array<ArrayBuffer>,\n data: string\n): Promise<ArrayBuffer> {\n const imported = await crypto.subtle.importKey(\n 'raw',\n key,\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign']\n );\n return crypto.subtle.sign('HMAC', imported, utf8.encode(data));\n}\n\n/** RFC 3986 encoding, as SigV4 wants it: only unreserved characters stay. */\nfunction encode(value: string): string {\n return encodeURIComponent(value).replace(\n /[!'()*]/g,\n (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`\n );\n}\n\n/**\n * Each path segment encoded once (the URL already decoded it). SigV4 asks non-S3 services to\n * encode the path twice; for the fixed, plain paths signed here (SES's\n * `/v2/email/outbound-emails`) the two agree. A path with reserved characters would need the\n * second pass before this signer could be used for it.\n */\nfunction canonicalPath(pathname: string): string {\n return pathname\n .split('/')\n .map((segment) => encode(decodeURIComponent(segment)))\n .join('/');\n}\n\n/**\n * The query as sent, each name and value re-encoded. Parsed by hand: URLSearchParams reads `+`\n * as a space, which would sign a value other than the one on the wire.\n */\nfunction canonicalQuery(search: string): string {\n if (search === '' || search === '?') return '';\n return search\n .slice(1)\n .split('&')\n .filter((pair) => pair !== '')\n .map((pair) => {\n const at = pair.indexOf('=');\n const name = at === -1 ? pair : pair.slice(0, at);\n const value = at === -1 ? '' : pair.slice(at + 1);\n return [encode(decodeURIComponent(name)), encode(decodeURIComponent(value))] as const;\n })\n .sort(([a, av], [b, bv]) => (a === b ? (av < bv ? -1 : av > bv ? 1 : 0) : a < b ? -1 : 1))\n .map(([name, value]) => `${name}=${value}`)\n .join('&');\n}\n\n/** The request's headers with `authorization` (and `x-amz-date`, the session token) added. */\nexport async function signRequest(request: SigV4Request): Promise<Record<string, string>> {\n const url = new URL(request.url);\n const datetime = request.datetime ?? new Date().toISOString().replace(/[:-]|\\.\\d{3}/g, '');\n const date = datetime.slice(0, 8);\n const { accessKeyId, secretAccessKey, sessionToken } = request.credentials;\n\n const headers: Record<string, string> = {};\n for (const [name, value] of Object.entries(request.headers ?? {})) {\n headers[name.toLowerCase()] = value.trim().replace(/\\s+/g, ' ');\n }\n headers.host = url.host;\n headers['x-amz-date'] = datetime;\n if (sessionToken !== undefined) headers['x-amz-security-token'] = sessionToken;\n\n const names = Object.keys(headers).sort();\n const signedHeaders = names.join(';');\n const canonical = [\n request.method.toUpperCase(),\n canonicalPath(url.pathname),\n canonicalQuery(url.search),\n ...names.map((name) => `${name}:${headers[name]}`),\n '',\n signedHeaders,\n await sha256(request.body ?? ''),\n ].join('\\n');\n\n const scope = `${date}/${request.region}/${request.service}/aws4_request`;\n const toSign = ['AWS4-HMAC-SHA256', datetime, scope, await sha256(canonical)].join('\\n');\n\n let key = await hmac(utf8.encode(`AWS4${secretAccessKey}`), date);\n for (const part of [request.region, request.service, 'aws4_request']) {\n key = await hmac(key, part);\n }\n const signature = hex(await hmac(key, toSign));\n\n const { host: _host, ...sent } = headers;\n return {\n ...sent,\n authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,\n };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shware/workflow",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ShwareHQ/shware-sdk.git"