@shware/workflow 6.1.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +19 -3
  2. package/dist/aws/index.cjs +3 -2
  3. package/dist/aws/index.cjs.map +1 -1
  4. package/dist/aws/index.mjs +3 -2
  5. package/dist/aws/index.mjs.map +1 -1
  6. package/dist/cloudflare/worker.cjs +1 -1
  7. package/dist/cloudflare/worker.cjs.map +1 -1
  8. package/dist/cloudflare/worker.mjs +1 -1
  9. package/dist/cloudflare/worker.mjs.map +1 -1
  10. package/dist/email-sender.cjs +8 -1
  11. package/dist/email-sender.cjs.map +1 -1
  12. package/dist/email-sender.d.cts +2 -2
  13. package/dist/email-sender.d.cts.map +1 -1
  14. package/dist/email-sender.d.mts +2 -2
  15. package/dist/email-sender.d.mts.map +1 -1
  16. package/dist/email-sender.mjs +9 -2
  17. package/dist/email-sender.mjs.map +1 -1
  18. package/dist/email.cjs +5 -0
  19. package/dist/email.cjs.map +1 -1
  20. package/dist/email.d.cts +2 -0
  21. package/dist/email.d.cts.map +1 -1
  22. package/dist/email.d.mts +2 -0
  23. package/dist/email.d.mts.map +1 -1
  24. package/dist/email.mjs +5 -1
  25. package/dist/email.mjs.map +1 -1
  26. package/dist/engine/condition.cjs +4 -2
  27. package/dist/engine/condition.cjs.map +1 -1
  28. package/dist/engine/condition.d.cts.map +1 -1
  29. package/dist/engine/condition.d.mts.map +1 -1
  30. package/dist/engine/condition.mjs +4 -2
  31. package/dist/engine/condition.mjs.map +1 -1
  32. package/dist/engine/interpreter.cjs +2 -1
  33. package/dist/engine/interpreter.cjs.map +1 -1
  34. package/dist/engine/interpreter.mjs +2 -1
  35. package/dist/engine/interpreter.mjs.map +1 -1
  36. package/dist/engine/ports.d.cts +19 -3
  37. package/dist/engine/ports.d.cts.map +1 -1
  38. package/dist/engine/ports.d.mts +19 -3
  39. package/dist/engine/ports.d.mts.map +1 -1
  40. package/dist/host/channels.cjs +17 -1
  41. package/dist/host/channels.cjs.map +1 -1
  42. package/dist/host/channels.d.cts.map +1 -1
  43. package/dist/host/channels.d.mts.map +1 -1
  44. package/dist/host/channels.mjs +17 -1
  45. package/dist/host/channels.mjs.map +1 -1
  46. package/dist/host/http.cjs +2 -2
  47. package/dist/host/http.cjs.map +1 -1
  48. package/dist/host/http.d.cts +3 -1
  49. package/dist/host/http.d.cts.map +1 -1
  50. package/dist/host/http.d.mts +3 -1
  51. package/dist/host/http.d.mts.map +1 -1
  52. package/dist/host/http.mjs +2 -2
  53. package/dist/host/http.mjs.map +1 -1
  54. package/dist/host/ingest.cjs +112 -13
  55. package/dist/host/ingest.cjs.map +1 -1
  56. package/dist/host/ingest.d.cts +21 -2
  57. package/dist/host/ingest.d.cts.map +1 -1
  58. package/dist/host/ingest.d.mts +21 -2
  59. package/dist/host/ingest.d.mts.map +1 -1
  60. package/dist/host/ingest.mjs +111 -14
  61. package/dist/host/ingest.mjs.map +1 -1
  62. package/dist/host/journey-host.cjs +2 -1
  63. package/dist/host/journey-host.cjs.map +1 -1
  64. package/dist/host/journey-host.d.cts +5 -2
  65. package/dist/host/journey-host.d.cts.map +1 -1
  66. package/dist/host/journey-host.d.mts +5 -2
  67. package/dist/host/journey-host.d.mts.map +1 -1
  68. package/dist/host/journey-host.mjs +2 -1
  69. package/dist/host/journey-host.mjs.map +1 -1
  70. package/dist/index.cjs +1 -0
  71. package/dist/index.d.cts +2 -2
  72. package/dist/index.d.mts +2 -2
  73. package/dist/index.mjs +2 -2
  74. package/dist/reporting/observer.cjs +1 -0
  75. package/dist/reporting/observer.cjs.map +1 -1
  76. package/dist/reporting/observer.mjs +1 -0
  77. package/dist/reporting/observer.mjs.map +1 -1
  78. package/dist/store/facts.cjs +18 -2
  79. package/dist/store/facts.cjs.map +1 -1
  80. package/dist/store/facts.d.cts +1 -0
  81. package/dist/store/facts.d.cts.map +1 -1
  82. package/dist/store/facts.d.mts +1 -0
  83. package/dist/store/facts.d.mts.map +1 -1
  84. package/dist/store/facts.mjs +18 -2
  85. package/dist/store/facts.mjs.map +1 -1
  86. package/dist/store/index.cjs.map +1 -1
  87. package/dist/store/index.d.cts +70 -10
  88. package/dist/store/index.d.cts.map +1 -1
  89. package/dist/store/index.d.mts +70 -10
  90. package/dist/store/index.d.mts.map +1 -1
  91. package/dist/store/index.mjs.map +1 -1
  92. package/dist/store/postgres-reporting.cjs +4 -0
  93. package/dist/store/postgres-reporting.cjs.map +1 -1
  94. package/dist/store/postgres-reporting.d.cts +1 -0
  95. package/dist/store/postgres-reporting.d.cts.map +1 -1
  96. package/dist/store/postgres-reporting.d.mts +1 -0
  97. package/dist/store/postgres-reporting.d.mts.map +1 -1
  98. package/dist/store/postgres-reporting.mjs +4 -0
  99. package/dist/store/postgres-reporting.mjs.map +1 -1
  100. package/dist/store/postgres.cjs +65 -11
  101. package/dist/store/postgres.cjs.map +1 -1
  102. package/dist/store/postgres.d.cts +13 -4
  103. package/dist/store/postgres.d.cts.map +1 -1
  104. package/dist/store/postgres.d.mts +13 -4
  105. package/dist/store/postgres.d.mts.map +1 -1
  106. package/dist/store/postgres.mjs +65 -11
  107. package/dist/store/postgres.mjs.map +1 -1
  108. package/dist/store/tables.cjs +4 -3
  109. package/dist/store/tables.cjs.map +1 -1
  110. package/dist/store/tables.d.cts.map +1 -1
  111. package/dist/store/tables.d.mts.map +1 -1
  112. package/dist/store/tables.mjs +4 -3
  113. package/dist/store/tables.mjs.map +1 -1
  114. package/package.json +1 -1
package/README.md CHANGED
@@ -691,6 +691,10 @@ const relay = await startApnsRelay({ port: 8280 });
691
691
  // wrangler dev --var APNS_RELAY_ORIGIN:http://localhost:8280
692
692
  ```
693
693
 
694
+ The relay sees the provider JWT, so production (`ENVIRONMENT` unset or
695
+ `production`) ignores `APNS_RELAY_ORIGIN`: it calls APNs directly and logs one
696
+ `console.error` saying the variable was ignored.
697
+
694
698
  ### Slack and Discord
695
699
 
696
700
  Team-channel messages go through incoming webhooks. The URL is a secret, so the
@@ -935,7 +939,7 @@ database.
935
939
  bundle live (see [Postgres](#postgres)); `POST /bundle/deploy` forces it.
936
940
  - **APNs in development.** `wrangler dev` (workerd) cannot make HTTP/2 subrequests: run
937
941
  `@shware/workflow/apns-relay` and point `APNS_RELAY_ORIGIN` at it. A deployed Worker needs no
938
- relay.
942
+ relay, and production ignores the variable.
939
943
 
940
944
  ## AWS Lambda
941
945
 
@@ -1193,7 +1197,9 @@ that version serves its first request, whatever share of traffic it has.
1193
1197
 
1194
1198
  `wrangler rollback` restores an older version, which does not apply itself over
1195
1199
  the newer one. Call `POST /bundle/deploy` after a rollback: it applies the
1196
- running version's bundle whatever is live.
1200
+ running version's bundle whatever is live, and records that version as the live
1201
+ one — so a newer version that serves again later puts its own bundle back, as a
1202
+ deploy always does.
1197
1203
 
1198
1204
  On Postgres the engine also keeps:
1199
1205
 
@@ -1212,7 +1218,9 @@ On Postgres the engine also keeps:
1212
1218
 
1213
1219
  ## HTTP surface
1214
1220
 
1215
- `handleRequest` serves the ingest API:
1221
+ `handleRequest` serves the ingest API. It applies the same production gate as
1222
+ the full host: with `ENVIRONMENT` resolving to `production` and no `API_TOKEN`,
1223
+ everything but `GET /health` answers 503.
1216
1224
 
1217
1225
  - `GET /health` is the only unauthenticated one. Every other route asks the
1218
1226
  host's `authorize`, or without one the `API_TOKEN` bearer: its scheme is
@@ -1298,6 +1306,14 @@ exception is an entry whose instance died: that row records a failure, not a
1298
1306
  journey the user received, so the next trigger reclaims it. Re-entry beyond that
1299
1307
  is a design question this package does not yet answer.
1300
1308
 
1309
+ The entry commits before the platform creates the instance, so a host cut off
1310
+ between the two leaves an entry with nothing behind it. Two things repair it: a
1311
+ retry of the same event (same `id` and `ts`) creates the missing instance — the
1312
+ instance id is the platform's identity too, so a second create is harmless — and
1313
+ the sweep's maintenance marks an entry left unadmitted for 15 minutes, whose
1314
+ instance the runtime does not know or reports ended, as failed, so the user's
1315
+ next trigger re-enters. Neither can start a second journey for the pair.
1316
+
1301
1317
  ## Exports
1302
1318
 
1303
1319
  | Subpath | Contents |
@@ -124,8 +124,9 @@ function journeyLambda(options) {
124
124
  const { store, env } = open(context);
125
125
  const request = require_aws_function_url.toRequest(event);
126
126
  if (new URL(request.url).pathname === "/deploy") return await require_aws_function_url.fromResponse(json({ error: "deploy by publishing a version (POST /bundle/deploy applies its own bundle)" }, 409), method);
127
- await host.applyOwnBundle(env, bundleVersion());
128
- return await require_aws_function_url.fromResponse(await host.handle(request, env, store), method);
127
+ const version = bundleVersion();
128
+ await host.applyOwnBundle(env, version);
129
+ return await require_aws_function_url.fromResponse(await host.handle(request, env, store, version), method);
129
130
  } catch (error) {
130
131
  console.error("[workflow] request failed", error);
131
132
  return require_aws_function_url.fromResponse(json({ error: "internal error" }, 500), method);
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["createJourneyHost","http2Fetch","longestJourneyMs","sdkLambdaApi","AsyncLocalStorage","AwsInstances","readWithRetry","PermanentError","executeJourney","AwsEngineStep","toRequest","fromResponse","sweepMaintenance","sweepSegmentsPaged","withDurableExecution","isFunctionUrlEvent","isRunInput"],"sources":["../../src/aws/index.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks';\nimport {\n type DurableContext,\n type DurableExecutionInvocationInput,\n type DurableExecutionInvocationOutput,\n withDurableExecution,\n} from '@aws/durable-execution-sdk-js';\nimport { longestJourneyMs } from '../engine/duration';\nimport type { JourneyOutcome } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport { executeJourney } from '../host/execute';\nimport { readWithRetry } from '../host/fresh-read';\nimport {\n SWEEP_PAGE_SIZE,\n type SweepResult,\n sweepMaintenance,\n sweepSegmentsPaged,\n} from '../host/ingest';\nimport { type JourneyHostOptions, createJourneyHost } from '../host/journey-host';\nimport type { JourneyEnv } from '../host/platform';\nimport { http2Fetch } from '../http2-client';\nimport type { BundleVersion, ExecutionRegistry, JourneyStore, StoreLease } from '../store/index';\nimport { AwsEngineStep } from './engine-step';\nimport {\n type FunctionUrlEvent,\n type FunctionUrlResult,\n fromResponse,\n isFunctionUrlEvent,\n toRequest,\n} from './function-url';\nimport { AwsInstances, type RunInput, isRunInput } from './instances';\nimport { type LambdaApi, sdkLambdaApi } from './lambda-api';\n\n/**\n * `@shware/workflow/aws` — the journey host as one Lambda durable function behind a Function\n * URL, on Postgres.\n *\n * The one handler tells three inputs apart by the event's top-level shape: a Function URL\n * request (the HTTP API — ingest, identify, devices, tracking, bundle, preview), an internal run\n * (one journey instance: one durable execution, started by an asynchronous invoke of this\n * function's own numbered version), and a scheduled maintenance tick. An HTTP body can never\n * pass for the other two: the Function URL always wraps it.\n *\n * const journeys = journeyLambda({\n * config,\n * env, // parsed from process.env (zod)\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database),\n * email: (env) => sesEmailTransport({ region: env.AWS_REGION }),\n * });\n * export const handler = journeys.handler;\n */\n\n/** The scheduled tick (EventBridge Scheduler's payload): the segment sweep and maintenance. */\nexport const SWEEP_EVENT_TYPE = '$workflow.sweep';\n\n/** What journeyLambda reads from its env: `JourneyEnv` without the binding it supplies itself. */\nexport type JourneyLambdaEnv = Omit<JourneyEnv, 'JOURNEY' | 'DB' | 'EMAIL'>;\n\nexport interface JourneyLambdaOptions<Env extends JourneyLambdaEnv> extends Omit<\n JourneyHostOptions<Env & JourneyEnv>,\n 'env' | 'store'\n> {\n /** The validated environment (parsed from `process.env` with zod by the app). */\n env: Env;\n /** The Postgres store: AWS needs its execution registry (`postgresJsStore`). */\n store: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /** The Lambda control plane; `@aws-sdk/client-lambda` with the execution role by default. */\n lambda?: LambdaApi;\n /** How long an unconfirmed start is retried (default 12 hours; keep it under the retention). */\n startRetryMs?: number;\n /**\n * How long an erasure waits after stopping an execution before it erases: an invocation\n * running at the stop goes on to its next checkpoint. Set it to at least the function's\n * timeout; 15 minutes (Lambda's maximum) by default.\n */\n stopDrainMs?: number;\n /**\n * The function's `DurableConfig.ExecutionTimeout`, in milliseconds. With it, a workflow that\n * can outlive it (`longestJourneyMs`) fails the handler's creation — a deploy's smoke test —\n * instead of being stopped by the platform midway and recorded as failed.\n */\n executionTimeoutMs?: number;\n}\n\n/**\n * The Lambda invocation context, as far as the handler's signature needs it — declared here so\n * the package's types do not depend on `@types/aws-lambda`. Lambda passes the full object.\n */\nexport interface LambdaContext {\n functionName: string;\n functionVersion: string;\n invokedFunctionArn: string;\n awsRequestId: string;\n getRemainingTimeInMillis(): number;\n}\n\nexport interface JourneyLambda {\n /** Export as the function's handler. */\n handler: (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ) => Promise<DurableExecutionInvocationOutput>;\n}\n\ninterface Invocation {\n lease?: Required<StoreLease>;\n}\n\n/** This function's ARN without its qualifier, from the ARN it was invoked under. */\nfunction unqualified(invokedFunctionArn: string): string {\n return invokedFunctionArn.split(':').slice(0, 7).join(':');\n}\n\nconst json = (body: unknown, status = 200) =>\n new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });\n\nexport function journeyLambda<Env extends JourneyLambdaEnv>(\n options: JourneyLambdaOptions<Env>\n): JourneyLambda {\n type HostEnv = Env & JourneyEnv;\n const {\n env: baseEnv,\n lambda: lambdaOption,\n startRetryMs,\n stopDrainMs,\n executionTimeoutMs,\n ...hostOptions\n } = options;\n const host = createJourneyHost<HostEnv>(\n { ...hostOptions, store: (env, database) => options.store(env, database) },\n // Node 24's fetch cannot reach APNs (HTTP/2 only): see http2-client.ts for when this can go\n { apnsFetch: http2Fetch() }\n );\n if (executionTimeoutMs !== undefined) {\n const tooLong = host\n .bundle()\n .workflows.filter((workflow) => longestJourneyMs(workflow) > executionTimeoutMs);\n if (tooLong.length > 0) {\n const days = (ms: number) => `${Math.ceil(ms / 86_400_000)} days`;\n throw new RangeError(\n `${tooLong.map((w) => `'${w.id}' can run ${days(longestJourneyMs(w))}`).join(', ')}, ` +\n `longer than the execution timeout (${days(executionTimeoutMs)}): raise ExecutionTimeout (up to 366 days)`\n );\n }\n }\n const lambda = lambdaOption ?? sdkLambdaApi();\n const ownHashes = new Set(host.bundle().workflows.map((workflow) => workflow.contentHash));\n const scope = new AsyncLocalStorage<Invocation>();\n\n /** The version this code was published as: `$LATEST` while developing. */\n const ownVersion = () => process.env.AWS_LAMBDA_FUNCTION_VERSION ?? '$LATEST';\n /** Numbered versions are ordered: the bundle compiled into a later one goes live by itself. */\n const bundleVersion = (): BundleVersion | undefined => {\n const version = ownVersion();\n return /^\\d+$/.test(version) ? { id: version, createdAt: Number(version) } : undefined;\n };\n\n /** The store and the env (with the instance port) of this invocation, opened on first use. */\n const open = (\n context: DurableContext\n ): { store: JourneyStore; env: HostEnv; executions: ExecutionRegistry } => {\n const invocation = scope.getStore();\n if (invocation === undefined) throw new Error('journeyLambda: outside an invocation');\n const placeholder = { ...baseEnv } as unknown as HostEnv;\n invocation.lease ??= host.lease(placeholder);\n const { store } = invocation.lease;\n const { executions } = store;\n const functionArn = unqualified(context.lambdaContext.invokedFunctionArn);\n const target = async (contentHash: string, version?: string): Promise<string> => {\n // An execution runs the code its IR was compiled with: the version whose bundle put the\n // route there, read in the same row as the hash — never a separately read \"live\" version,\n // which a release between the two reads would make another bundle's\n if (version !== undefined && /^\\d+$/.test(version)) return `${functionArn}:${version}`;\n // A forced deploy records no version: only this code's own workflows can run here\n if (ownHashes.has(contentHash)) return `${functionArn}:${ownVersion()}`;\n throw new Error(`no published version is known to carry workflow IR ${contentHash}`);\n };\n const instances = new AwsInstances({\n store,\n executions,\n lambda,\n target,\n ...(startRetryMs === undefined ? {} : { startRetryMs }),\n ...(stopDrainMs === undefined ? {} : { stopDrainMs }),\n });\n return { store, env: { ...baseEnv, JOURNEY: instances }, executions };\n };\n\n const run = async (input: RunInput, context: DurableContext): Promise<JourneyOutcome> => {\n const { store, env, executions } = open(context);\n const { instanceId, params } = input;\n // Only a start the router recorded may run, byte for byte: the function can be invoked by\n // anything its policy lets through, and an input of this shape would otherwise run any\n // workflow for any user. A stopped execution (an erasure) may not come back either. Outside\n // any step — a step would replay the first invocation's \"not stopped\" on every wake — so the\n // execution fails at once on a refusal; only the read itself is retried, briefly, when the\n // database stumbled.\n const record = await readWithRetry(() => executions.get(instanceId), {\n what: 'run entry',\n instanceId,\n });\n if (\n record === undefined ||\n record.stoppedAt !== undefined ||\n record.input !== JSON.stringify(input)\n ) {\n throw new PermanentError('journeyLambda: run input is not a recorded start');\n }\n // Running is the proof the start got through, whatever became of the invoke's response\n await context.step('$workflow:started', async () => {\n await executions.markSubmitted(\n instanceId,\n context.executionContext.durableExecutionArn,\n Date.now()\n );\n });\n return executeJourney(\n {\n instanceId,\n params,\n enteredAtMs: input.enteredAtMs,\n step: new AwsEngineStep(context, store, executions, lambda, params.userId, instanceId),\n },\n host.runParts(env, store, params)\n );\n };\n\n const http = (event: FunctionUrlEvent, context: DurableContext): Promise<FunctionUrlResult> => {\n const serve = async (): Promise<FunctionUrlResult> => {\n const method = event.requestContext.http.method;\n try {\n const { store, env } = open(context);\n const request = toRequest(event);\n // Only the bundle compiled into a version may route: one is never swapped in by hand\n if (new URL(request.url).pathname === '/deploy') {\n return await fromResponse(\n json(\n {\n error:\n 'deploy by publishing a version (POST /bundle/deploy applies its own bundle)',\n },\n 409\n ),\n method\n );\n }\n await host.applyOwnBundle(env, bundleVersion());\n return await fromResponse(await host.handle(request, env, store), method);\n } catch (error) {\n console.error('[workflow] request failed', error);\n return fromResponse(json({ error: 'internal error' }, 500), method);\n }\n };\n // A read (the bundle, a preview, a tracked link or pixel) answers straight from the handler:\n // its body can be large, and a step would checkpoint all of it. A write runs in one step,\n // never retried — the caller is waiting, and retries the request itself\n const method = event.requestContext.http.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return serve();\n return context.step('http', serve, { retryStrategy: () => ({ shouldRetry: false }) });\n };\n\n /**\n * The sweep as durable steps, one per page of users: an invocation cut short (the function's\n * timeout) is re-invoked, and the re-invocation replays the pages already checkpointed\n * instead of evaluating everyone again. No step retries on its own — a page that failed is\n * evaluated again by the next tick.\n */\n const sweep = async (context: DurableContext): Promise<SweepResult> => {\n const once = { retryStrategy: () => ({ shouldRetry: false }) };\n await context.step(\n 'sweep:maintain',\n async () => {\n const { store, env } = open(context);\n await host.applyOwnBundle(env, bundleVersion());\n await sweepMaintenance(env, store);\n },\n once\n );\n let users = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (let n = 0; ; n++) {\n const page = await context.step(\n `sweep:page:${n}`,\n async () => {\n const { store, env } = open(context);\n return sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE });\n },\n once\n );\n users += page.users;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = await context.step(\n 'sweep:snapshot',\n async () => {\n const { store } = open(context);\n return store.reporting.snapshotSegments(Date.now());\n },\n once\n );\n return { users, started, sizes };\n };\n\n const durable = withDurableExecution(async (input: unknown, context: DurableContext) => {\n if (isFunctionUrlEvent(input)) return http(input, context);\n if (isRunInput(input)) return run(input, context);\n if (\n typeof input === 'object' &&\n input !== null &&\n (input as { type?: unknown }).type === SWEEP_EVENT_TYPE\n ) {\n return sweep(context);\n }\n throw new Error('journeyLambda: unrecognized event (not a Function URL request, run or sweep)');\n });\n\n const handler = async (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ): Promise<DurableExecutionInvocationOutput> => {\n const invocation: Invocation = {};\n try {\n // The durable wrapper reads the whole context Lambda passed, not just the fields above\n return await scope.run(invocation, () =>\n durable(event, context as Parameters<typeof durable>[1])\n );\n } finally {\n // Outside the durable wrapper: a suspended run returns PENDING without unwinding the\n // handler, so a `finally` in there would never release the connection\n await invocation.lease?.close().catch((error: unknown) => {\n console.error('[workflow] closing the store failed', error);\n });\n }\n };\n\n return { handler };\n}\n\nexport { AwsEngineStep, stepRetry } from './engine-step';\nexport { AwsInstances, RUN_EVENT_TYPE, executionName, isRunInput } from './instances';\nexport type { AwsInstancesOptions, RunInput } from './instances';\nexport { sdkLambdaApi } from './lambda-api';\nexport { bearerMatches, timingSafeEqual } from '../host/auth';\nexport type { Authorize } from '../host/auth';\nexport type { ExecutionState, LambdaApi } from './lambda-api';\nexport { fromResponse, isFunctionUrlEvent, toRequest } from './function-url';\nexport type { FunctionUrlEvent, FunctionUrlResult } from './function-url';\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["createJourneyHost","http2Fetch","longestJourneyMs","sdkLambdaApi","AsyncLocalStorage","AwsInstances","readWithRetry","PermanentError","executeJourney","AwsEngineStep","toRequest","fromResponse","sweepMaintenance","sweepSegmentsPaged","withDurableExecution","isFunctionUrlEvent","isRunInput"],"sources":["../../src/aws/index.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks';\nimport {\n type DurableContext,\n type DurableExecutionInvocationInput,\n type DurableExecutionInvocationOutput,\n withDurableExecution,\n} from '@aws/durable-execution-sdk-js';\nimport { longestJourneyMs } from '../engine/duration';\nimport type { JourneyOutcome } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport { executeJourney } from '../host/execute';\nimport { readWithRetry } from '../host/fresh-read';\nimport {\n SWEEP_PAGE_SIZE,\n type SweepResult,\n sweepMaintenance,\n sweepSegmentsPaged,\n} from '../host/ingest';\nimport { type JourneyHostOptions, createJourneyHost } from '../host/journey-host';\nimport type { JourneyEnv } from '../host/platform';\nimport { http2Fetch } from '../http2-client';\nimport type { BundleVersion, ExecutionRegistry, JourneyStore, StoreLease } from '../store/index';\nimport { AwsEngineStep } from './engine-step';\nimport {\n type FunctionUrlEvent,\n type FunctionUrlResult,\n fromResponse,\n isFunctionUrlEvent,\n toRequest,\n} from './function-url';\nimport { AwsInstances, type RunInput, isRunInput } from './instances';\nimport { type LambdaApi, sdkLambdaApi } from './lambda-api';\n\n/**\n * `@shware/workflow/aws` — the journey host as one Lambda durable function behind a Function\n * URL, on Postgres.\n *\n * The one handler tells three inputs apart by the event's top-level shape: a Function URL\n * request (the HTTP API — ingest, identify, devices, tracking, bundle, preview), an internal run\n * (one journey instance: one durable execution, started by an asynchronous invoke of this\n * function's own numbered version), and a scheduled maintenance tick. An HTTP body can never\n * pass for the other two: the Function URL always wraps it.\n *\n * const journeys = journeyLambda({\n * config,\n * env, // parsed from process.env (zod)\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database),\n * email: (env) => sesEmailTransport({ region: env.AWS_REGION }),\n * });\n * export const handler = journeys.handler;\n */\n\n/** The scheduled tick (EventBridge Scheduler's payload): the segment sweep and maintenance. */\nexport const SWEEP_EVENT_TYPE = '$workflow.sweep';\n\n/** What journeyLambda reads from its env: `JourneyEnv` without the binding it supplies itself. */\nexport type JourneyLambdaEnv = Omit<JourneyEnv, 'JOURNEY' | 'DB' | 'EMAIL'>;\n\nexport interface JourneyLambdaOptions<Env extends JourneyLambdaEnv> extends Omit<\n JourneyHostOptions<Env & JourneyEnv>,\n 'env' | 'store'\n> {\n /** The validated environment (parsed from `process.env` with zod by the app). */\n env: Env;\n /** The Postgres store: AWS needs its execution registry (`postgresJsStore`). */\n store: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /** The Lambda control plane; `@aws-sdk/client-lambda` with the execution role by default. */\n lambda?: LambdaApi;\n /** How long an unconfirmed start is retried (default 12 hours; keep it under the retention). */\n startRetryMs?: number;\n /**\n * How long an erasure waits after stopping an execution before it erases: an invocation\n * running at the stop goes on to its next checkpoint. Set it to at least the function's\n * timeout; 15 minutes (Lambda's maximum) by default.\n */\n stopDrainMs?: number;\n /**\n * The function's `DurableConfig.ExecutionTimeout`, in milliseconds. With it, a workflow that\n * can outlive it (`longestJourneyMs`) fails the handler's creation — a deploy's smoke test —\n * instead of being stopped by the platform midway and recorded as failed.\n */\n executionTimeoutMs?: number;\n}\n\n/**\n * The Lambda invocation context, as far as the handler's signature needs it — declared here so\n * the package's types do not depend on `@types/aws-lambda`. Lambda passes the full object.\n */\nexport interface LambdaContext {\n functionName: string;\n functionVersion: string;\n invokedFunctionArn: string;\n awsRequestId: string;\n getRemainingTimeInMillis(): number;\n}\n\nexport interface JourneyLambda {\n /** Export as the function's handler. */\n handler: (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ) => Promise<DurableExecutionInvocationOutput>;\n}\n\ninterface Invocation {\n lease?: Required<StoreLease>;\n}\n\n/** This function's ARN without its qualifier, from the ARN it was invoked under. */\nfunction unqualified(invokedFunctionArn: string): string {\n return invokedFunctionArn.split(':').slice(0, 7).join(':');\n}\n\nconst json = (body: unknown, status = 200) =>\n new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });\n\nexport function journeyLambda<Env extends JourneyLambdaEnv>(\n options: JourneyLambdaOptions<Env>\n): JourneyLambda {\n type HostEnv = Env & JourneyEnv;\n const {\n env: baseEnv,\n lambda: lambdaOption,\n startRetryMs,\n stopDrainMs,\n executionTimeoutMs,\n ...hostOptions\n } = options;\n const host = createJourneyHost<HostEnv>(\n { ...hostOptions, store: (env, database) => options.store(env, database) },\n // Node 24's fetch cannot reach APNs (HTTP/2 only): see http2-client.ts for when this can go\n { apnsFetch: http2Fetch() }\n );\n if (executionTimeoutMs !== undefined) {\n const tooLong = host\n .bundle()\n .workflows.filter((workflow) => longestJourneyMs(workflow) > executionTimeoutMs);\n if (tooLong.length > 0) {\n const days = (ms: number) => `${Math.ceil(ms / 86_400_000)} days`;\n throw new RangeError(\n `${tooLong.map((w) => `'${w.id}' can run ${days(longestJourneyMs(w))}`).join(', ')}, ` +\n `longer than the execution timeout (${days(executionTimeoutMs)}): raise ExecutionTimeout (up to 366 days)`\n );\n }\n }\n const lambda = lambdaOption ?? sdkLambdaApi();\n const ownHashes = new Set(host.bundle().workflows.map((workflow) => workflow.contentHash));\n const scope = new AsyncLocalStorage<Invocation>();\n\n /** The version this code was published as: `$LATEST` while developing. */\n const ownVersion = () => process.env.AWS_LAMBDA_FUNCTION_VERSION ?? '$LATEST';\n /** Numbered versions are ordered: the bundle compiled into a later one goes live by itself. */\n const bundleVersion = (): BundleVersion | undefined => {\n const version = ownVersion();\n return /^\\d+$/.test(version) ? { id: version, createdAt: Number(version) } : undefined;\n };\n\n /** The store and the env (with the instance port) of this invocation, opened on first use. */\n const open = (\n context: DurableContext\n ): { store: JourneyStore; env: HostEnv; executions: ExecutionRegistry } => {\n const invocation = scope.getStore();\n if (invocation === undefined) throw new Error('journeyLambda: outside an invocation');\n const placeholder = { ...baseEnv } as unknown as HostEnv;\n invocation.lease ??= host.lease(placeholder);\n const { store } = invocation.lease;\n const { executions } = store;\n const functionArn = unqualified(context.lambdaContext.invokedFunctionArn);\n const target = async (contentHash: string, version?: string): Promise<string> => {\n // An execution runs the code its IR was compiled with: the version whose bundle put the\n // route there, read in the same row as the hash — never a separately read \"live\" version,\n // which a release between the two reads would make another bundle's\n if (version !== undefined && /^\\d+$/.test(version)) return `${functionArn}:${version}`;\n // A forced deploy records no version: only this code's own workflows can run here\n if (ownHashes.has(contentHash)) return `${functionArn}:${ownVersion()}`;\n throw new Error(`no published version is known to carry workflow IR ${contentHash}`);\n };\n const instances = new AwsInstances({\n store,\n executions,\n lambda,\n target,\n ...(startRetryMs === undefined ? {} : { startRetryMs }),\n ...(stopDrainMs === undefined ? {} : { stopDrainMs }),\n });\n return { store, env: { ...baseEnv, JOURNEY: instances }, executions };\n };\n\n const run = async (input: RunInput, context: DurableContext): Promise<JourneyOutcome> => {\n const { store, env, executions } = open(context);\n const { instanceId, params } = input;\n // Only a start the router recorded may run, byte for byte: the function can be invoked by\n // anything its policy lets through, and an input of this shape would otherwise run any\n // workflow for any user. A stopped execution (an erasure) may not come back either. Outside\n // any step — a step would replay the first invocation's \"not stopped\" on every wake — so the\n // execution fails at once on a refusal; only the read itself is retried, briefly, when the\n // database stumbled.\n const record = await readWithRetry(() => executions.get(instanceId), {\n what: 'run entry',\n instanceId,\n });\n if (\n record === undefined ||\n record.stoppedAt !== undefined ||\n record.input !== JSON.stringify(input)\n ) {\n throw new PermanentError('journeyLambda: run input is not a recorded start');\n }\n // Running is the proof the start got through, whatever became of the invoke's response\n await context.step('$workflow:started', async () => {\n await executions.markSubmitted(\n instanceId,\n context.executionContext.durableExecutionArn,\n Date.now()\n );\n });\n return executeJourney(\n {\n instanceId,\n params,\n enteredAtMs: input.enteredAtMs,\n step: new AwsEngineStep(context, store, executions, lambda, params.userId, instanceId),\n },\n host.runParts(env, store, params)\n );\n };\n\n const http = (event: FunctionUrlEvent, context: DurableContext): Promise<FunctionUrlResult> => {\n const serve = async (): Promise<FunctionUrlResult> => {\n const method = event.requestContext.http.method;\n try {\n const { store, env } = open(context);\n const request = toRequest(event);\n // Only the bundle compiled into a version may route: one is never swapped in by hand\n if (new URL(request.url).pathname === '/deploy') {\n return await fromResponse(\n json(\n {\n error:\n 'deploy by publishing a version (POST /bundle/deploy applies its own bundle)',\n },\n 409\n ),\n method\n );\n }\n const version = bundleVersion();\n await host.applyOwnBundle(env, version);\n return await fromResponse(await host.handle(request, env, store, version), method);\n } catch (error) {\n console.error('[workflow] request failed', error);\n return fromResponse(json({ error: 'internal error' }, 500), method);\n }\n };\n // A read (the bundle, a preview, a tracked link or pixel) answers straight from the handler:\n // its body can be large, and a step would checkpoint all of it. A write runs in one step,\n // never retried — the caller is waiting, and retries the request itself\n const method = event.requestContext.http.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return serve();\n return context.step('http', serve, { retryStrategy: () => ({ shouldRetry: false }) });\n };\n\n /**\n * The sweep as durable steps, one per page of users: an invocation cut short (the function's\n * timeout) is re-invoked, and the re-invocation replays the pages already checkpointed\n * instead of evaluating everyone again. No step retries on its own — a page that failed is\n * evaluated again by the next tick.\n */\n const sweep = async (context: DurableContext): Promise<SweepResult> => {\n const once = { retryStrategy: () => ({ shouldRetry: false }) };\n await context.step(\n 'sweep:maintain',\n async () => {\n const { store, env } = open(context);\n await host.applyOwnBundle(env, bundleVersion());\n await sweepMaintenance(env, store);\n },\n once\n );\n let users = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (let n = 0; ; n++) {\n const page = await context.step(\n `sweep:page:${n}`,\n async () => {\n const { store, env } = open(context);\n return sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE });\n },\n once\n );\n users += page.users;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = await context.step(\n 'sweep:snapshot',\n async () => {\n const { store } = open(context);\n return store.reporting.snapshotSegments(Date.now());\n },\n once\n );\n return { users, started, sizes };\n };\n\n const durable = withDurableExecution(async (input: unknown, context: DurableContext) => {\n if (isFunctionUrlEvent(input)) return http(input, context);\n if (isRunInput(input)) return run(input, context);\n if (\n typeof input === 'object' &&\n input !== null &&\n (input as { type?: unknown }).type === SWEEP_EVENT_TYPE\n ) {\n return sweep(context);\n }\n throw new Error('journeyLambda: unrecognized event (not a Function URL request, run or sweep)');\n });\n\n const handler = async (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ): Promise<DurableExecutionInvocationOutput> => {\n const invocation: Invocation = {};\n try {\n // The durable wrapper reads the whole context Lambda passed, not just the fields above\n return await scope.run(invocation, () =>\n durable(event, context as Parameters<typeof durable>[1])\n );\n } finally {\n // Outside the durable wrapper: a suspended run returns PENDING without unwinding the\n // handler, so a `finally` in there would never release the connection\n await invocation.lease?.close().catch((error: unknown) => {\n console.error('[workflow] closing the store failed', error);\n });\n }\n };\n\n return { handler };\n}\n\nexport { AwsEngineStep, stepRetry } from './engine-step';\nexport { AwsInstances, RUN_EVENT_TYPE, executionName, isRunInput } from './instances';\nexport type { AwsInstancesOptions, RunInput } from './instances';\nexport { sdkLambdaApi } from './lambda-api';\nexport { bearerMatches, timingSafeEqual } from '../host/auth';\nexport type { Authorize } from '../host/auth';\nexport type { ExecutionState, LambdaApi } from './lambda-api';\nexport { fromResponse, isFunctionUrlEvent, toRequest } from './function-url';\nexport type { FunctionUrlEvent, FunctionUrlResult } from './function-url';\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqDA,MAAa,mBAAmB;;AAwDhC,SAAS,YAAY,oBAAoC;CACvD,OAAO,mBAAmB,MAAM,GAAG,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,KAAK,GAAG;AAC3D;AAEA,MAAM,QAAQ,MAAe,SAAS,QACpC,IAAI,SAAS,KAAK,UAAU,IAAI,GAAG;CAAE;CAAQ,SAAS,EAAE,gBAAgB,mBAAmB;AAAE,CAAC;AAEhG,SAAgB,cACd,SACe;CAEf,MAAM,EACJ,KAAK,SACL,QAAQ,cACR,cACA,aACA,oBACA,GAAG,gBACD;CACJ,MAAM,OAAOA,0BAAAA,kBACX;EAAE,GAAG;EAAa,QAAQ,KAAK,aAAa,QAAQ,MAAM,KAAK,QAAQ;CAAE,GAEzE,EAAE,WAAWC,qBAAAA,WAAW,EAAE,CAC5B;CACA,IAAI,uBAAuB,KAAA,GAAW;EACpC,MAAM,UAAU,KACb,OAAO,CAAC,CACR,UAAU,QAAQ,aAAaC,wBAAAA,iBAAiB,QAAQ,IAAI,kBAAkB;EACjF,IAAI,QAAQ,SAAS,GAAG;GACtB,MAAM,QAAQ,OAAe,GAAG,KAAK,KAAK,KAAK,KAAU,EAAE;GAC3D,MAAM,IAAI,WACR,GAAG,QAAQ,KAAK,MAAM,IAAI,EAAE,GAAG,YAAY,KAAKA,wBAAAA,iBAAiB,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE,uCAC3C,KAAK,kBAAkB,EAAE,2CACnE;EACF;CACF;CACA,MAAM,SAAS,gBAAgBC,uBAAAA,aAAa;CAC5C,MAAM,YAAY,IAAI,IAAI,KAAK,OAAO,CAAC,CAAC,UAAU,KAAK,aAAa,SAAS,WAAW,CAAC;CACzF,MAAM,QAAQ,IAAIC,iBAAAA,kBAA8B;;CAGhD,MAAM,mBAAmB,QAAQ,IAAI,+BAA+B;;CAEpE,MAAM,sBAAiD;EACrD,MAAM,UAAU,WAAW;EAC3B,OAAO,QAAQ,KAAK,OAAO,IAAI;GAAE,IAAI;GAAS,WAAW,OAAO,OAAO;EAAE,IAAI,KAAA;CAC/E;;CAGA,MAAM,QACJ,YACyE;EACzE,MAAM,aAAa,MAAM,SAAS;EAClC,IAAI,eAAe,KAAA,GAAW,MAAM,IAAI,MAAM,sCAAsC;EACpF,MAAM,cAAc,EAAE,GAAG,QAAQ;EACjC,WAAW,UAAU,KAAK,MAAM,WAAW;EAC3C,MAAM,EAAE,UAAU,WAAW;EAC7B,MAAM,EAAE,eAAe;EACvB,MAAM,cAAc,YAAY,QAAQ,cAAc,kBAAkB;EACxE,MAAM,SAAS,OAAO,aAAqB,YAAsC;GAI/E,IAAI,YAAY,KAAA,KAAa,QAAQ,KAAK,OAAO,GAAG,OAAO,GAAG,YAAY,GAAG;GAE7E,IAAI,UAAU,IAAI,WAAW,GAAG,OAAO,GAAG,YAAY,GAAG,WAAW;GACpE,MAAM,IAAI,MAAM,sDAAsD,aAAa;EACrF;EACA,MAAM,YAAY,IAAIC,sBAAAA,aAAa;GACjC;GACA;GACA;GACA;GACA,GAAI,iBAAiB,KAAA,IAAY,CAAC,IAAI,EAAE,aAAa;GACrD,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;EACrD,CAAC;EACD,OAAO;GAAE;GAAO,KAAK;IAAE,GAAG;IAAS,SAAS;GAAU;GAAG;EAAW;CACtE;CAEA,MAAM,MAAM,OAAO,OAAiB,YAAqD;EACvF,MAAM,EAAE,OAAO,KAAK,eAAe,KAAK,OAAO;EAC/C,MAAM,EAAE,YAAY,WAAW;EAO/B,MAAM,SAAS,MAAMC,wBAAAA,oBAAoB,WAAW,IAAI,UAAU,GAAG;GACnE,MAAM;GACN;EACF,CAAC;EACD,IACE,WAAW,KAAA,KACX,OAAO,cAAc,KAAA,KACrB,OAAO,UAAU,KAAK,UAAU,KAAK,GAErC,MAAM,IAAIC,qBAAAA,eAAe,kDAAkD;EAG7E,MAAM,QAAQ,KAAK,qBAAqB,YAAY;GAClD,MAAM,WAAW,cACf,YACA,QAAQ,iBAAiB,qBACzB,KAAK,IAAI,CACX;EACF,CAAC;EACD,OAAOC,qBAAAA,eACL;GACE;GACA;GACA,aAAa,MAAM;GACnB,MAAM,IAAIC,wBAAAA,cAAc,SAAS,OAAO,YAAY,QAAQ,OAAO,QAAQ,UAAU;EACvF,GACA,KAAK,SAAS,KAAK,OAAO,MAAM,CAClC;CACF;CAEA,MAAM,QAAQ,OAAyB,YAAwD;EAC7F,MAAM,QAAQ,YAAwC;GACpD,MAAM,SAAS,MAAM,eAAe,KAAK;GACzC,IAAI;IACF,MAAM,EAAE,OAAO,QAAQ,KAAK,OAAO;IACnC,MAAM,UAAUC,yBAAAA,UAAU,KAAK;IAE/B,IAAI,IAAI,IAAI,QAAQ,GAAG,CAAC,CAAC,aAAa,WACpC,OAAO,MAAMC,yBAAAA,aACX,KACE,EACE,OACE,8EACJ,GACA,GACF,GACA,MACF;IAEF,MAAM,UAAU,cAAc;IAC9B,MAAM,KAAK,eAAe,KAAK,OAAO;IACtC,OAAO,MAAMA,yBAAAA,aAAa,MAAM,KAAK,OAAO,SAAS,KAAK,OAAO,OAAO,GAAG,MAAM;GACnF,SAAS,OAAO;IACd,QAAQ,MAAM,6BAA6B,KAAK;IAChD,OAAOA,yBAAAA,aAAa,KAAK,EAAE,OAAO,iBAAiB,GAAG,GAAG,GAAG,MAAM;GACpE;EACF;EAIA,MAAM,SAAS,MAAM,eAAe,KAAK,OAAO,YAAY;EAC5D,IAAI,WAAW,SAAS,WAAW,QAAQ,OAAO,MAAM;EACxD,OAAO,QAAQ,KAAK,QAAQ,OAAO,EAAE,sBAAsB,EAAE,aAAa,MAAM,GAAG,CAAC;CACtF;;;;;;;CAQA,MAAM,QAAQ,OAAO,YAAkD;EACrE,MAAM,OAAO,EAAE,sBAAsB,EAAE,aAAa,MAAM,GAAG;EAC7D,MAAM,QAAQ,KACZ,kBACA,YAAY;GACV,MAAM,EAAE,OAAO,QAAQ,KAAK,OAAO;GACnC,MAAM,KAAK,eAAe,KAAK,cAAc,CAAC;GAC9C,MAAMC,oBAAAA,iBAAiB,KAAK,KAAK;EACnC,GACA,IACF;EACA,IAAI,QAAQ;EACZ,MAAM,UAAoB,CAAC;EAC3B,IAAI;EACJ,KAAK,IAAI,IAAI,IAAK,KAAK;GACrB,MAAM,OAAO,MAAM,QAAQ,KACzB,cAAc,KACd,YAAY;IACV,MAAM,EAAE,OAAO,QAAQ,KAAK,OAAO;IACnC,OAAOC,oBAAAA,mBAAmB,KAAK,OAAO;KAAE;KAAO,OAAA;IAAuB,CAAC;GACzE,GACA,IACF;GACA,SAAS,KAAK;GACd,QAAQ,KAAK,GAAG,KAAK,OAAO;GAC5B,IAAI,CAAC,KAAK,QAAQ,KAAK,eAAe,KAAA,GAAW;GACjD,QAAQ,KAAK;EACf;EACA,MAAM,QAAQ,MAAM,QAAQ,KAC1B,kBACA,YAAY;GACV,MAAM,EAAE,UAAU,KAAK,OAAO;GAC9B,OAAO,MAAM,UAAU,iBAAiB,KAAK,IAAI,CAAC;EACpD,GACA,IACF;EACA,OAAO;GAAE;GAAO;GAAS;EAAM;CACjC;CAEA,MAAM,WAAA,GAAUC,8BAAAA,qBAAAA,CAAqB,OAAO,OAAgB,YAA4B;EACtF,IAAIC,yBAAAA,mBAAmB,KAAK,GAAG,OAAO,KAAK,OAAO,OAAO;EACzD,IAAIC,sBAAAA,WAAW,KAAK,GAAG,OAAO,IAAI,OAAO,OAAO;EAChD,IACE,OAAO,UAAU,YACjB,UAAU,QACT,MAA6B,SAAA,mBAE9B,OAAO,MAAM,OAAO;EAEtB,MAAM,IAAI,MAAM,8EAA8E;CAChG,CAAC;CAED,MAAM,UAAU,OACd,OACA,YAC8C;EAC9C,MAAM,aAAyB,CAAC;EAChC,IAAI;GAEF,OAAO,MAAM,MAAM,IAAI,kBACrB,QAAQ,OAAO,OAAwC,CACzD;EACF,UAAU;GAGR,MAAM,WAAW,OAAO,MAAM,CAAC,CAAC,OAAO,UAAmB;IACxD,QAAQ,MAAM,uCAAuC,KAAK;GAC5D,CAAC;EACH;CACF;CAEA,OAAO,EAAE,QAAQ;AACnB"}
@@ -123,8 +123,9 @@ function journeyLambda(options) {
123
123
  const { store, env } = open(context);
124
124
  const request = toRequest(event);
125
125
  if (new URL(request.url).pathname === "/deploy") return await fromResponse(json({ error: "deploy by publishing a version (POST /bundle/deploy applies its own bundle)" }, 409), method);
126
- await host.applyOwnBundle(env, bundleVersion());
127
- return await fromResponse(await host.handle(request, env, store), method);
126
+ const version = bundleVersion();
127
+ await host.applyOwnBundle(env, version);
128
+ return await fromResponse(await host.handle(request, env, store, version), method);
128
129
  } catch (error) {
129
130
  console.error("[workflow] request failed", error);
130
131
  return fromResponse(json({ error: "internal error" }, 500), method);
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/aws/index.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks';\nimport {\n type DurableContext,\n type DurableExecutionInvocationInput,\n type DurableExecutionInvocationOutput,\n withDurableExecution,\n} from '@aws/durable-execution-sdk-js';\nimport { longestJourneyMs } from '../engine/duration';\nimport type { JourneyOutcome } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport { executeJourney } from '../host/execute';\nimport { readWithRetry } from '../host/fresh-read';\nimport {\n SWEEP_PAGE_SIZE,\n type SweepResult,\n sweepMaintenance,\n sweepSegmentsPaged,\n} from '../host/ingest';\nimport { type JourneyHostOptions, createJourneyHost } from '../host/journey-host';\nimport type { JourneyEnv } from '../host/platform';\nimport { http2Fetch } from '../http2-client';\nimport type { BundleVersion, ExecutionRegistry, JourneyStore, StoreLease } from '../store/index';\nimport { AwsEngineStep } from './engine-step';\nimport {\n type FunctionUrlEvent,\n type FunctionUrlResult,\n fromResponse,\n isFunctionUrlEvent,\n toRequest,\n} from './function-url';\nimport { AwsInstances, type RunInput, isRunInput } from './instances';\nimport { type LambdaApi, sdkLambdaApi } from './lambda-api';\n\n/**\n * `@shware/workflow/aws` — the journey host as one Lambda durable function behind a Function\n * URL, on Postgres.\n *\n * The one handler tells three inputs apart by the event's top-level shape: a Function URL\n * request (the HTTP API — ingest, identify, devices, tracking, bundle, preview), an internal run\n * (one journey instance: one durable execution, started by an asynchronous invoke of this\n * function's own numbered version), and a scheduled maintenance tick. An HTTP body can never\n * pass for the other two: the Function URL always wraps it.\n *\n * const journeys = journeyLambda({\n * config,\n * env, // parsed from process.env (zod)\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database),\n * email: (env) => sesEmailTransport({ region: env.AWS_REGION }),\n * });\n * export const handler = journeys.handler;\n */\n\n/** The scheduled tick (EventBridge Scheduler's payload): the segment sweep and maintenance. */\nexport const SWEEP_EVENT_TYPE = '$workflow.sweep';\n\n/** What journeyLambda reads from its env: `JourneyEnv` without the binding it supplies itself. */\nexport type JourneyLambdaEnv = Omit<JourneyEnv, 'JOURNEY' | 'DB' | 'EMAIL'>;\n\nexport interface JourneyLambdaOptions<Env extends JourneyLambdaEnv> extends Omit<\n JourneyHostOptions<Env & JourneyEnv>,\n 'env' | 'store'\n> {\n /** The validated environment (parsed from `process.env` with zod by the app). */\n env: Env;\n /** The Postgres store: AWS needs its execution registry (`postgresJsStore`). */\n store: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /** The Lambda control plane; `@aws-sdk/client-lambda` with the execution role by default. */\n lambda?: LambdaApi;\n /** How long an unconfirmed start is retried (default 12 hours; keep it under the retention). */\n startRetryMs?: number;\n /**\n * How long an erasure waits after stopping an execution before it erases: an invocation\n * running at the stop goes on to its next checkpoint. Set it to at least the function's\n * timeout; 15 minutes (Lambda's maximum) by default.\n */\n stopDrainMs?: number;\n /**\n * The function's `DurableConfig.ExecutionTimeout`, in milliseconds. With it, a workflow that\n * can outlive it (`longestJourneyMs`) fails the handler's creation — a deploy's smoke test —\n * instead of being stopped by the platform midway and recorded as failed.\n */\n executionTimeoutMs?: number;\n}\n\n/**\n * The Lambda invocation context, as far as the handler's signature needs it — declared here so\n * the package's types do not depend on `@types/aws-lambda`. Lambda passes the full object.\n */\nexport interface LambdaContext {\n functionName: string;\n functionVersion: string;\n invokedFunctionArn: string;\n awsRequestId: string;\n getRemainingTimeInMillis(): number;\n}\n\nexport interface JourneyLambda {\n /** Export as the function's handler. */\n handler: (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ) => Promise<DurableExecutionInvocationOutput>;\n}\n\ninterface Invocation {\n lease?: Required<StoreLease>;\n}\n\n/** This function's ARN without its qualifier, from the ARN it was invoked under. */\nfunction unqualified(invokedFunctionArn: string): string {\n return invokedFunctionArn.split(':').slice(0, 7).join(':');\n}\n\nconst json = (body: unknown, status = 200) =>\n new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });\n\nexport function journeyLambda<Env extends JourneyLambdaEnv>(\n options: JourneyLambdaOptions<Env>\n): JourneyLambda {\n type HostEnv = Env & JourneyEnv;\n const {\n env: baseEnv,\n lambda: lambdaOption,\n startRetryMs,\n stopDrainMs,\n executionTimeoutMs,\n ...hostOptions\n } = options;\n const host = createJourneyHost<HostEnv>(\n { ...hostOptions, store: (env, database) => options.store(env, database) },\n // Node 24's fetch cannot reach APNs (HTTP/2 only): see http2-client.ts for when this can go\n { apnsFetch: http2Fetch() }\n );\n if (executionTimeoutMs !== undefined) {\n const tooLong = host\n .bundle()\n .workflows.filter((workflow) => longestJourneyMs(workflow) > executionTimeoutMs);\n if (tooLong.length > 0) {\n const days = (ms: number) => `${Math.ceil(ms / 86_400_000)} days`;\n throw new RangeError(\n `${tooLong.map((w) => `'${w.id}' can run ${days(longestJourneyMs(w))}`).join(', ')}, ` +\n `longer than the execution timeout (${days(executionTimeoutMs)}): raise ExecutionTimeout (up to 366 days)`\n );\n }\n }\n const lambda = lambdaOption ?? sdkLambdaApi();\n const ownHashes = new Set(host.bundle().workflows.map((workflow) => workflow.contentHash));\n const scope = new AsyncLocalStorage<Invocation>();\n\n /** The version this code was published as: `$LATEST` while developing. */\n const ownVersion = () => process.env.AWS_LAMBDA_FUNCTION_VERSION ?? '$LATEST';\n /** Numbered versions are ordered: the bundle compiled into a later one goes live by itself. */\n const bundleVersion = (): BundleVersion | undefined => {\n const version = ownVersion();\n return /^\\d+$/.test(version) ? { id: version, createdAt: Number(version) } : undefined;\n };\n\n /** The store and the env (with the instance port) of this invocation, opened on first use. */\n const open = (\n context: DurableContext\n ): { store: JourneyStore; env: HostEnv; executions: ExecutionRegistry } => {\n const invocation = scope.getStore();\n if (invocation === undefined) throw new Error('journeyLambda: outside an invocation');\n const placeholder = { ...baseEnv } as unknown as HostEnv;\n invocation.lease ??= host.lease(placeholder);\n const { store } = invocation.lease;\n const { executions } = store;\n const functionArn = unqualified(context.lambdaContext.invokedFunctionArn);\n const target = async (contentHash: string, version?: string): Promise<string> => {\n // An execution runs the code its IR was compiled with: the version whose bundle put the\n // route there, read in the same row as the hash — never a separately read \"live\" version,\n // which a release between the two reads would make another bundle's\n if (version !== undefined && /^\\d+$/.test(version)) return `${functionArn}:${version}`;\n // A forced deploy records no version: only this code's own workflows can run here\n if (ownHashes.has(contentHash)) return `${functionArn}:${ownVersion()}`;\n throw new Error(`no published version is known to carry workflow IR ${contentHash}`);\n };\n const instances = new AwsInstances({\n store,\n executions,\n lambda,\n target,\n ...(startRetryMs === undefined ? {} : { startRetryMs }),\n ...(stopDrainMs === undefined ? {} : { stopDrainMs }),\n });\n return { store, env: { ...baseEnv, JOURNEY: instances }, executions };\n };\n\n const run = async (input: RunInput, context: DurableContext): Promise<JourneyOutcome> => {\n const { store, env, executions } = open(context);\n const { instanceId, params } = input;\n // Only a start the router recorded may run, byte for byte: the function can be invoked by\n // anything its policy lets through, and an input of this shape would otherwise run any\n // workflow for any user. A stopped execution (an erasure) may not come back either. Outside\n // any step — a step would replay the first invocation's \"not stopped\" on every wake — so the\n // execution fails at once on a refusal; only the read itself is retried, briefly, when the\n // database stumbled.\n const record = await readWithRetry(() => executions.get(instanceId), {\n what: 'run entry',\n instanceId,\n });\n if (\n record === undefined ||\n record.stoppedAt !== undefined ||\n record.input !== JSON.stringify(input)\n ) {\n throw new PermanentError('journeyLambda: run input is not a recorded start');\n }\n // Running is the proof the start got through, whatever became of the invoke's response\n await context.step('$workflow:started', async () => {\n await executions.markSubmitted(\n instanceId,\n context.executionContext.durableExecutionArn,\n Date.now()\n );\n });\n return executeJourney(\n {\n instanceId,\n params,\n enteredAtMs: input.enteredAtMs,\n step: new AwsEngineStep(context, store, executions, lambda, params.userId, instanceId),\n },\n host.runParts(env, store, params)\n );\n };\n\n const http = (event: FunctionUrlEvent, context: DurableContext): Promise<FunctionUrlResult> => {\n const serve = async (): Promise<FunctionUrlResult> => {\n const method = event.requestContext.http.method;\n try {\n const { store, env } = open(context);\n const request = toRequest(event);\n // Only the bundle compiled into a version may route: one is never swapped in by hand\n if (new URL(request.url).pathname === '/deploy') {\n return await fromResponse(\n json(\n {\n error:\n 'deploy by publishing a version (POST /bundle/deploy applies its own bundle)',\n },\n 409\n ),\n method\n );\n }\n await host.applyOwnBundle(env, bundleVersion());\n return await fromResponse(await host.handle(request, env, store), method);\n } catch (error) {\n console.error('[workflow] request failed', error);\n return fromResponse(json({ error: 'internal error' }, 500), method);\n }\n };\n // A read (the bundle, a preview, a tracked link or pixel) answers straight from the handler:\n // its body can be large, and a step would checkpoint all of it. A write runs in one step,\n // never retried — the caller is waiting, and retries the request itself\n const method = event.requestContext.http.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return serve();\n return context.step('http', serve, { retryStrategy: () => ({ shouldRetry: false }) });\n };\n\n /**\n * The sweep as durable steps, one per page of users: an invocation cut short (the function's\n * timeout) is re-invoked, and the re-invocation replays the pages already checkpointed\n * instead of evaluating everyone again. No step retries on its own — a page that failed is\n * evaluated again by the next tick.\n */\n const sweep = async (context: DurableContext): Promise<SweepResult> => {\n const once = { retryStrategy: () => ({ shouldRetry: false }) };\n await context.step(\n 'sweep:maintain',\n async () => {\n const { store, env } = open(context);\n await host.applyOwnBundle(env, bundleVersion());\n await sweepMaintenance(env, store);\n },\n once\n );\n let users = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (let n = 0; ; n++) {\n const page = await context.step(\n `sweep:page:${n}`,\n async () => {\n const { store, env } = open(context);\n return sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE });\n },\n once\n );\n users += page.users;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = await context.step(\n 'sweep:snapshot',\n async () => {\n const { store } = open(context);\n return store.reporting.snapshotSegments(Date.now());\n },\n once\n );\n return { users, started, sizes };\n };\n\n const durable = withDurableExecution(async (input: unknown, context: DurableContext) => {\n if (isFunctionUrlEvent(input)) return http(input, context);\n if (isRunInput(input)) return run(input, context);\n if (\n typeof input === 'object' &&\n input !== null &&\n (input as { type?: unknown }).type === SWEEP_EVENT_TYPE\n ) {\n return sweep(context);\n }\n throw new Error('journeyLambda: unrecognized event (not a Function URL request, run or sweep)');\n });\n\n const handler = async (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ): Promise<DurableExecutionInvocationOutput> => {\n const invocation: Invocation = {};\n try {\n // The durable wrapper reads the whole context Lambda passed, not just the fields above\n return await scope.run(invocation, () =>\n durable(event, context as Parameters<typeof durable>[1])\n );\n } finally {\n // Outside the durable wrapper: a suspended run returns PENDING without unwinding the\n // handler, so a `finally` in there would never release the connection\n await invocation.lease?.close().catch((error: unknown) => {\n console.error('[workflow] closing the store failed', error);\n });\n }\n };\n\n return { handler };\n}\n\nexport { AwsEngineStep, stepRetry } from './engine-step';\nexport { AwsInstances, RUN_EVENT_TYPE, executionName, isRunInput } from './instances';\nexport type { AwsInstancesOptions, RunInput } from './instances';\nexport { sdkLambdaApi } from './lambda-api';\nexport { bearerMatches, timingSafeEqual } from '../host/auth';\nexport type { Authorize } from '../host/auth';\nexport type { ExecutionState, LambdaApi } from './lambda-api';\nexport { fromResponse, isFunctionUrlEvent, toRequest } from './function-url';\nexport type { FunctionUrlEvent, FunctionUrlResult } from './function-url';\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/aws/index.ts"],"sourcesContent":["import { AsyncLocalStorage } from 'node:async_hooks';\nimport {\n type DurableContext,\n type DurableExecutionInvocationInput,\n type DurableExecutionInvocationOutput,\n withDurableExecution,\n} from '@aws/durable-execution-sdk-js';\nimport { longestJourneyMs } from '../engine/duration';\nimport type { JourneyOutcome } from '../engine/ports';\nimport { PermanentError } from '../engine/retry';\nimport { executeJourney } from '../host/execute';\nimport { readWithRetry } from '../host/fresh-read';\nimport {\n SWEEP_PAGE_SIZE,\n type SweepResult,\n sweepMaintenance,\n sweepSegmentsPaged,\n} from '../host/ingest';\nimport { type JourneyHostOptions, createJourneyHost } from '../host/journey-host';\nimport type { JourneyEnv } from '../host/platform';\nimport { http2Fetch } from '../http2-client';\nimport type { BundleVersion, ExecutionRegistry, JourneyStore, StoreLease } from '../store/index';\nimport { AwsEngineStep } from './engine-step';\nimport {\n type FunctionUrlEvent,\n type FunctionUrlResult,\n fromResponse,\n isFunctionUrlEvent,\n toRequest,\n} from './function-url';\nimport { AwsInstances, type RunInput, isRunInput } from './instances';\nimport { type LambdaApi, sdkLambdaApi } from './lambda-api';\n\n/**\n * `@shware/workflow/aws` — the journey host as one Lambda durable function behind a Function\n * URL, on Postgres.\n *\n * The one handler tells three inputs apart by the event's top-level shape: a Function URL\n * request (the HTTP API — ingest, identify, devices, tracking, bundle, preview), an internal run\n * (one journey instance: one durable execution, started by an asynchronous invoke of this\n * function's own numbered version), and a scheduled maintenance tick. An HTTP body can never\n * pass for the other two: the Function URL always wraps it.\n *\n * const journeys = journeyLambda({\n * config,\n * env, // parsed from process.env (zod)\n * store: (env, database) => postgresJsStore(env.DATABASE_URL, database),\n * email: (env) => sesEmailTransport({ region: env.AWS_REGION }),\n * });\n * export const handler = journeys.handler;\n */\n\n/** The scheduled tick (EventBridge Scheduler's payload): the segment sweep and maintenance. */\nexport const SWEEP_EVENT_TYPE = '$workflow.sweep';\n\n/** What journeyLambda reads from its env: `JourneyEnv` without the binding it supplies itself. */\nexport type JourneyLambdaEnv = Omit<JourneyEnv, 'JOURNEY' | 'DB' | 'EMAIL'>;\n\nexport interface JourneyLambdaOptions<Env extends JourneyLambdaEnv> extends Omit<\n JourneyHostOptions<Env & JourneyEnv>,\n 'env' | 'store'\n> {\n /** The validated environment (parsed from `process.env` with zod by the app). */\n env: Env;\n /** The Postgres store: AWS needs its execution registry (`postgresJsStore`). */\n store: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /** The Lambda control plane; `@aws-sdk/client-lambda` with the execution role by default. */\n lambda?: LambdaApi;\n /** How long an unconfirmed start is retried (default 12 hours; keep it under the retention). */\n startRetryMs?: number;\n /**\n * How long an erasure waits after stopping an execution before it erases: an invocation\n * running at the stop goes on to its next checkpoint. Set it to at least the function's\n * timeout; 15 minutes (Lambda's maximum) by default.\n */\n stopDrainMs?: number;\n /**\n * The function's `DurableConfig.ExecutionTimeout`, in milliseconds. With it, a workflow that\n * can outlive it (`longestJourneyMs`) fails the handler's creation — a deploy's smoke test —\n * instead of being stopped by the platform midway and recorded as failed.\n */\n executionTimeoutMs?: number;\n}\n\n/**\n * The Lambda invocation context, as far as the handler's signature needs it — declared here so\n * the package's types do not depend on `@types/aws-lambda`. Lambda passes the full object.\n */\nexport interface LambdaContext {\n functionName: string;\n functionVersion: string;\n invokedFunctionArn: string;\n awsRequestId: string;\n getRemainingTimeInMillis(): number;\n}\n\nexport interface JourneyLambda {\n /** Export as the function's handler. */\n handler: (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ) => Promise<DurableExecutionInvocationOutput>;\n}\n\ninterface Invocation {\n lease?: Required<StoreLease>;\n}\n\n/** This function's ARN without its qualifier, from the ARN it was invoked under. */\nfunction unqualified(invokedFunctionArn: string): string {\n return invokedFunctionArn.split(':').slice(0, 7).join(':');\n}\n\nconst json = (body: unknown, status = 200) =>\n new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });\n\nexport function journeyLambda<Env extends JourneyLambdaEnv>(\n options: JourneyLambdaOptions<Env>\n): JourneyLambda {\n type HostEnv = Env & JourneyEnv;\n const {\n env: baseEnv,\n lambda: lambdaOption,\n startRetryMs,\n stopDrainMs,\n executionTimeoutMs,\n ...hostOptions\n } = options;\n const host = createJourneyHost<HostEnv>(\n { ...hostOptions, store: (env, database) => options.store(env, database) },\n // Node 24's fetch cannot reach APNs (HTTP/2 only): see http2-client.ts for when this can go\n { apnsFetch: http2Fetch() }\n );\n if (executionTimeoutMs !== undefined) {\n const tooLong = host\n .bundle()\n .workflows.filter((workflow) => longestJourneyMs(workflow) > executionTimeoutMs);\n if (tooLong.length > 0) {\n const days = (ms: number) => `${Math.ceil(ms / 86_400_000)} days`;\n throw new RangeError(\n `${tooLong.map((w) => `'${w.id}' can run ${days(longestJourneyMs(w))}`).join(', ')}, ` +\n `longer than the execution timeout (${days(executionTimeoutMs)}): raise ExecutionTimeout (up to 366 days)`\n );\n }\n }\n const lambda = lambdaOption ?? sdkLambdaApi();\n const ownHashes = new Set(host.bundle().workflows.map((workflow) => workflow.contentHash));\n const scope = new AsyncLocalStorage<Invocation>();\n\n /** The version this code was published as: `$LATEST` while developing. */\n const ownVersion = () => process.env.AWS_LAMBDA_FUNCTION_VERSION ?? '$LATEST';\n /** Numbered versions are ordered: the bundle compiled into a later one goes live by itself. */\n const bundleVersion = (): BundleVersion | undefined => {\n const version = ownVersion();\n return /^\\d+$/.test(version) ? { id: version, createdAt: Number(version) } : undefined;\n };\n\n /** The store and the env (with the instance port) of this invocation, opened on first use. */\n const open = (\n context: DurableContext\n ): { store: JourneyStore; env: HostEnv; executions: ExecutionRegistry } => {\n const invocation = scope.getStore();\n if (invocation === undefined) throw new Error('journeyLambda: outside an invocation');\n const placeholder = { ...baseEnv } as unknown as HostEnv;\n invocation.lease ??= host.lease(placeholder);\n const { store } = invocation.lease;\n const { executions } = store;\n const functionArn = unqualified(context.lambdaContext.invokedFunctionArn);\n const target = async (contentHash: string, version?: string): Promise<string> => {\n // An execution runs the code its IR was compiled with: the version whose bundle put the\n // route there, read in the same row as the hash — never a separately read \"live\" version,\n // which a release between the two reads would make another bundle's\n if (version !== undefined && /^\\d+$/.test(version)) return `${functionArn}:${version}`;\n // A forced deploy records no version: only this code's own workflows can run here\n if (ownHashes.has(contentHash)) return `${functionArn}:${ownVersion()}`;\n throw new Error(`no published version is known to carry workflow IR ${contentHash}`);\n };\n const instances = new AwsInstances({\n store,\n executions,\n lambda,\n target,\n ...(startRetryMs === undefined ? {} : { startRetryMs }),\n ...(stopDrainMs === undefined ? {} : { stopDrainMs }),\n });\n return { store, env: { ...baseEnv, JOURNEY: instances }, executions };\n };\n\n const run = async (input: RunInput, context: DurableContext): Promise<JourneyOutcome> => {\n const { store, env, executions } = open(context);\n const { instanceId, params } = input;\n // Only a start the router recorded may run, byte for byte: the function can be invoked by\n // anything its policy lets through, and an input of this shape would otherwise run any\n // workflow for any user. A stopped execution (an erasure) may not come back either. Outside\n // any step — a step would replay the first invocation's \"not stopped\" on every wake — so the\n // execution fails at once on a refusal; only the read itself is retried, briefly, when the\n // database stumbled.\n const record = await readWithRetry(() => executions.get(instanceId), {\n what: 'run entry',\n instanceId,\n });\n if (\n record === undefined ||\n record.stoppedAt !== undefined ||\n record.input !== JSON.stringify(input)\n ) {\n throw new PermanentError('journeyLambda: run input is not a recorded start');\n }\n // Running is the proof the start got through, whatever became of the invoke's response\n await context.step('$workflow:started', async () => {\n await executions.markSubmitted(\n instanceId,\n context.executionContext.durableExecutionArn,\n Date.now()\n );\n });\n return executeJourney(\n {\n instanceId,\n params,\n enteredAtMs: input.enteredAtMs,\n step: new AwsEngineStep(context, store, executions, lambda, params.userId, instanceId),\n },\n host.runParts(env, store, params)\n );\n };\n\n const http = (event: FunctionUrlEvent, context: DurableContext): Promise<FunctionUrlResult> => {\n const serve = async (): Promise<FunctionUrlResult> => {\n const method = event.requestContext.http.method;\n try {\n const { store, env } = open(context);\n const request = toRequest(event);\n // Only the bundle compiled into a version may route: one is never swapped in by hand\n if (new URL(request.url).pathname === '/deploy') {\n return await fromResponse(\n json(\n {\n error:\n 'deploy by publishing a version (POST /bundle/deploy applies its own bundle)',\n },\n 409\n ),\n method\n );\n }\n const version = bundleVersion();\n await host.applyOwnBundle(env, version);\n return await fromResponse(await host.handle(request, env, store, version), method);\n } catch (error) {\n console.error('[workflow] request failed', error);\n return fromResponse(json({ error: 'internal error' }, 500), method);\n }\n };\n // A read (the bundle, a preview, a tracked link or pixel) answers straight from the handler:\n // its body can be large, and a step would checkpoint all of it. A write runs in one step,\n // never retried — the caller is waiting, and retries the request itself\n const method = event.requestContext.http.method.toUpperCase();\n if (method === 'GET' || method === 'HEAD') return serve();\n return context.step('http', serve, { retryStrategy: () => ({ shouldRetry: false }) });\n };\n\n /**\n * The sweep as durable steps, one per page of users: an invocation cut short (the function's\n * timeout) is re-invoked, and the re-invocation replays the pages already checkpointed\n * instead of evaluating everyone again. No step retries on its own — a page that failed is\n * evaluated again by the next tick.\n */\n const sweep = async (context: DurableContext): Promise<SweepResult> => {\n const once = { retryStrategy: () => ({ shouldRetry: false }) };\n await context.step(\n 'sweep:maintain',\n async () => {\n const { store, env } = open(context);\n await host.applyOwnBundle(env, bundleVersion());\n await sweepMaintenance(env, store);\n },\n once\n );\n let users = 0;\n const started: string[] = [];\n let after: string | undefined;\n for (let n = 0; ; n++) {\n const page = await context.step(\n `sweep:page:${n}`,\n async () => {\n const { store, env } = open(context);\n return sweepSegmentsPaged(env, store, { after, limit: SWEEP_PAGE_SIZE });\n },\n once\n );\n users += page.users;\n started.push(...page.started);\n if (!page.more || page.lastUserId === undefined) break;\n after = page.lastUserId;\n }\n const sizes = await context.step(\n 'sweep:snapshot',\n async () => {\n const { store } = open(context);\n return store.reporting.snapshotSegments(Date.now());\n },\n once\n );\n return { users, started, sizes };\n };\n\n const durable = withDurableExecution(async (input: unknown, context: DurableContext) => {\n if (isFunctionUrlEvent(input)) return http(input, context);\n if (isRunInput(input)) return run(input, context);\n if (\n typeof input === 'object' &&\n input !== null &&\n (input as { type?: unknown }).type === SWEEP_EVENT_TYPE\n ) {\n return sweep(context);\n }\n throw new Error('journeyLambda: unrecognized event (not a Function URL request, run or sweep)');\n });\n\n const handler = async (\n event: DurableExecutionInvocationInput,\n context: LambdaContext\n ): Promise<DurableExecutionInvocationOutput> => {\n const invocation: Invocation = {};\n try {\n // The durable wrapper reads the whole context Lambda passed, not just the fields above\n return await scope.run(invocation, () =>\n durable(event, context as Parameters<typeof durable>[1])\n );\n } finally {\n // Outside the durable wrapper: a suspended run returns PENDING without unwinding the\n // handler, so a `finally` in there would never release the connection\n await invocation.lease?.close().catch((error: unknown) => {\n console.error('[workflow] closing the store failed', error);\n });\n }\n };\n\n return { handler };\n}\n\nexport { AwsEngineStep, stepRetry } from './engine-step';\nexport { AwsInstances, RUN_EVENT_TYPE, executionName, isRunInput } from './instances';\nexport type { AwsInstancesOptions, RunInput } from './instances';\nexport { sdkLambdaApi } from './lambda-api';\nexport { bearerMatches, timingSafeEqual } from '../host/auth';\nexport type { Authorize } from '../host/auth';\nexport type { ExecutionState, LambdaApi } from './lambda-api';\nexport { fromResponse, isFunctionUrlEvent, toRequest } from './function-url';\nexport type { FunctionUrlEvent, FunctionUrlResult } from './function-url';\n"],"mappings":"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"}
@@ -70,7 +70,7 @@ function journeyWorker(options) {
70
70
  }
71
71
  const { store, close } = host.lease(env);
72
72
  try {
73
- return await host.handle(request, env, store);
73
+ return await host.handle(request, env, store, ownVersion(env));
74
74
  } finally {
75
75
  if (ctx === void 0) await close();
76
76
  else ctx.waitUntil(close());
@@ -1 +1 @@
1
- {"version":3,"file":"worker.cjs","names":["createJourneyHost","JourneyRunner","cloudflareJourneyBinding","EnvValidationError"],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import type { JourneyRunParts } from '../host/execute';\nimport type { SweepResult } from '../host/ingest';\nimport {\n EnvValidationError,\n type JourneyHostOptions,\n createJourneyHost,\n} from '../host/journey-host';\nimport type { JourneyEnv, JourneyParams } from '../host/platform';\nimport type { BundleVersion, JourneyStore } from '../store/index';\nimport { cloudflareJourneyBinding } from './bindings';\nimport { JourneyRunner } from './runner';\n\nexport { EnvValidationError } from '../host/journey-host';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/**\n * `journeyWorker`'s options: the host's (config, store, email transport, observer, ...). The\n * email transport is the host's choice — `cloudflareEmailTransport({ binding: env.EMAIL })` for\n * Cloudflare Email, `sesEmailTransport(...)` for SES.\n */\nexport type JourneyWorkerOptions<Env extends JourneyEnv> = JourneyHostOptions<Env>;\n\nexport interface JourneyWorker<Env extends JourneyEnv> {\n /** Export this under the `class_name` of the wrangler `workflows` binding. */\n Runner: new (...args: never[]) => JourneyRunner;\n /** The Worker's fetch handler: `/health`, `/events`, `/identify`, `/devices`, `/messages/opened`, `/segments/sweep`, `/t/*` (email tracking), `/deploy`, `/bundle`, `/bundle/deploy` and `/preview/*`. */\n fetch: (request: Request, env: Env, ctx?: ExecutionContextLike) => Promise<Response>;\n /**\n * The segment sweep (`sweepSegments`): membership of every deployed segment for every known\n * user, the journeys of segments entered by the clock alone (`not(performed(e.login, { within:\n * '7 days' }))` has no event to ride on), and each segment's size. Also served as\n * `POST /segments/sweep`.\n */\n sweep: (env: Env) => Promise<SweepResult>;\n /** The cron trigger's handler: runs the sweep. Export it beside `fetch` with a `triggers.crons` entry. */\n scheduled: (controller: unknown, env: Env, ctx: ExecutionContextLike) => Promise<void>;\n}\n\n/**\n * A complete journey host in one call: the runner class with the app's store,\n * outlets and runtime plugged in, and the fetch handler that serves the ingest\n * API around them.\n *\n * const journeys = journeyWorker<Env>({\n * config, // workflow.config.ts (defineConfig)\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database),\n * });\n * export const MyRunner = journeys.Runner;\n * export default { fetch: journeys.fetch };\n */\nexport function journeyWorker<Env extends JourneyEnv>(\n options: JourneyWorkerOptions<Env>\n): JourneyWorker<Env> {\n const host = createJourneyHost(options);\n\n class Runner extends JourneyRunner {\n private readonly leases = new WeakMap<JourneyStore, () => Promise<void>>();\n\n protected override createStore(): JourneyStore {\n // A run fails at its start on an invalid environment, not at the send that needs it\n const { store, close } = host.lease(host.checkEnv(this.env as Env));\n this.leases.set(store, close);\n return store;\n }\n\n protected override async closeStore(store: JourneyStore): Promise<void> {\n await this.leases.get(store)?.();\n }\n\n protected override createRunParts(store: JourneyStore, params: JourneyParams): JourneyRunParts {\n return host.runParts(host.checkEnv(this.hostEnv() as Env), store, params);\n }\n }\n\n /**\n * The validated environment, its Workflow binding behind the host's port (an unknown instance\n * answers `InstanceNotFoundError`). Everything below reads this one, never the raw env.\n */\n const hostEnv = (rawEnv: Env): Env => {\n const env = host.checkEnv(rawEnv);\n return { ...env, JOURNEY: cloudflareJourneyBinding(env.JOURNEY) };\n };\n\n /** CF's version metadata: the bundle compiled into each new version goes live by itself. */\n const ownVersion = (env: Env): BundleVersion | undefined => {\n const metadata = env.CF_VERSION_METADATA;\n if (metadata === undefined) return undefined;\n return { id: metadata.id, createdAt: Date.parse(metadata.timestamp) };\n };\n\n const fetch = async (request: Request, rawEnv: Env, ctx?: ExecutionContextLike) => {\n let env: Env;\n try {\n env = hostEnv(rawEnv);\n } catch (error) {\n if (!(error instanceof EnvValidationError)) throw error;\n console.error(`[workflow] ${error.message}`);\n return new Response(JSON.stringify({ error: error.message }), {\n status: 500,\n headers: { 'content-type': 'application/json' },\n });\n }\n const pending = host.applyOwnBundle(env, ownVersion(env));\n // In the background where the runtime has waitUntil; inline otherwise (tests)\n if (pending !== undefined) {\n if (ctx === undefined) await pending;\n else ctx.waitUntil(pending);\n }\n const { store, close } = host.lease(env);\n try {\n return await host.handle(request, env, store);\n } finally {\n // After the response when the runtime offers waitUntil, inline otherwise (tests).\n if (ctx === undefined) await close();\n else ctx.waitUntil(close());\n }\n };\n\n const sweep = (rawEnv: Env) => host.sweep(hostEnv(rawEnv));\n\n const scheduled = async (_controller: unknown, rawEnv: Env, _ctx: ExecutionContextLike) => {\n // Awaited, not handed to waitUntil: the runtime keeps a scheduled handler alive for as long\n // as its promise runs, while a sweep only parked in waitUntil can be cut off by the\n // handler returning, and its failure would surface nowhere. A version with no traffic yet\n // goes live here; the sweep then reads its segments.\n const env = hostEnv(rawEnv);\n await host.applyOwnBundle(env, ownVersion(env));\n await host.sweep(env);\n };\n\n return { Runner, fetch, sweep, scheduled };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAsDA,SAAgB,cACd,SACoB;CACpB,MAAM,OAAOA,0BAAAA,kBAAkB,OAAO;CAEtC,MAAM,eAAeC,0BAAAA,cAAc;EACjC,yBAA0B,IAAI,QAA2C;EAEzE,cAA+C;GAE7C,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,KAAK,SAAS,KAAK,GAAU,CAAC;GAClE,KAAK,OAAO,IAAI,OAAO,KAAK;GAC5B,OAAO;EACT;EAEA,MAAyB,WAAW,OAAoC;GACtE,MAAM,KAAK,OAAO,IAAI,KAAK,CAAC,GAAG;EACjC;EAEA,eAAkC,OAAqB,QAAwC;GAC7F,OAAO,KAAK,SAAS,KAAK,SAAS,KAAK,QAAQ,CAAQ,GAAG,OAAO,MAAM;EAC1E;CACF;;;;;CAMA,MAAM,WAAW,WAAqB;EACpC,MAAM,MAAM,KAAK,SAAS,MAAM;EAChC,OAAO;GAAE,GAAG;GAAK,SAASC,4BAAAA,yBAAyB,IAAI,OAAO;EAAE;CAClE;;CAGA,MAAM,cAAc,QAAwC;EAC1D,MAAM,WAAW,IAAI;EACrB,IAAI,aAAa,KAAA,GAAW,OAAO,KAAA;EACnC,OAAO;GAAE,IAAI,SAAS;GAAI,WAAW,KAAK,MAAM,SAAS,SAAS;EAAE;CACtE;CAEA,MAAM,QAAQ,OAAO,SAAkB,QAAa,QAA+B;EACjF,IAAI;EACJ,IAAI;GACF,MAAM,QAAQ,MAAM;EACtB,SAAS,OAAO;GACd,IAAI,EAAE,iBAAiBC,0BAAAA,qBAAqB,MAAM;GAClD,QAAQ,MAAM,cAAc,MAAM,SAAS;GAC3C,OAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,MAAM,QAAQ,CAAC,GAAG;IAC5D,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;GAChD,CAAC;EACH;EACA,MAAM,UAAU,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAExD,IAAI,YAAY,KAAA,GAAW;GACzB,IAAI,QAAQ,KAAA,GAAW,MAAM;QACxB,IAAI,UAAU,OAAO;EAC5B;EACA,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,GAAG;EACvC,IAAI;GACF,OAAO,MAAM,KAAK,OAAO,SAAS,KAAK,KAAK;EAC9C,UAAU;GAER,IAAI,QAAQ,KAAA,GAAW,MAAM,MAAM;QAC9B,IAAI,UAAU,MAAM,CAAC;EAC5B;CACF;CAEA,MAAM,SAAS,WAAgB,KAAK,MAAM,QAAQ,MAAM,CAAC;CAEzD,MAAM,YAAY,OAAO,aAAsB,QAAa,SAA+B;EAKzF,MAAM,MAAM,QAAQ,MAAM;EAC1B,MAAM,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAC9C,MAAM,KAAK,MAAM,GAAG;CACtB;CAEA,OAAO;EAAE;EAAQ;EAAO;EAAO;CAAU;AAC3C"}
1
+ {"version":3,"file":"worker.cjs","names":["createJourneyHost","JourneyRunner","cloudflareJourneyBinding","EnvValidationError"],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import type { JourneyRunParts } from '../host/execute';\nimport type { SweepResult } from '../host/ingest';\nimport {\n EnvValidationError,\n type JourneyHostOptions,\n createJourneyHost,\n} from '../host/journey-host';\nimport type { JourneyEnv, JourneyParams } from '../host/platform';\nimport type { BundleVersion, JourneyStore } from '../store/index';\nimport { cloudflareJourneyBinding } from './bindings';\nimport { JourneyRunner } from './runner';\n\nexport { EnvValidationError } from '../host/journey-host';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/**\n * `journeyWorker`'s options: the host's (config, store, email transport, observer, ...). The\n * email transport is the host's choice — `cloudflareEmailTransport({ binding: env.EMAIL })` for\n * Cloudflare Email, `sesEmailTransport(...)` for SES.\n */\nexport type JourneyWorkerOptions<Env extends JourneyEnv> = JourneyHostOptions<Env>;\n\nexport interface JourneyWorker<Env extends JourneyEnv> {\n /** Export this under the `class_name` of the wrangler `workflows` binding. */\n Runner: new (...args: never[]) => JourneyRunner;\n /** The Worker's fetch handler: `/health`, `/events`, `/identify`, `/devices`, `/messages/opened`, `/segments/sweep`, `/t/*` (email tracking), `/deploy`, `/bundle`, `/bundle/deploy` and `/preview/*`. */\n fetch: (request: Request, env: Env, ctx?: ExecutionContextLike) => Promise<Response>;\n /**\n * The segment sweep (`sweepSegments`): membership of every deployed segment for every known\n * user, the journeys of segments entered by the clock alone (`not(performed(e.login, { within:\n * '7 days' }))` has no event to ride on), and each segment's size. Also served as\n * `POST /segments/sweep`.\n */\n sweep: (env: Env) => Promise<SweepResult>;\n /** The cron trigger's handler: runs the sweep. Export it beside `fetch` with a `triggers.crons` entry. */\n scheduled: (controller: unknown, env: Env, ctx: ExecutionContextLike) => Promise<void>;\n}\n\n/**\n * A complete journey host in one call: the runner class with the app's store,\n * outlets and runtime plugged in, and the fetch handler that serves the ingest\n * API around them.\n *\n * const journeys = journeyWorker<Env>({\n * config, // workflow.config.ts (defineConfig)\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database),\n * });\n * export const MyRunner = journeys.Runner;\n * export default { fetch: journeys.fetch };\n */\nexport function journeyWorker<Env extends JourneyEnv>(\n options: JourneyWorkerOptions<Env>\n): JourneyWorker<Env> {\n const host = createJourneyHost(options);\n\n class Runner extends JourneyRunner {\n private readonly leases = new WeakMap<JourneyStore, () => Promise<void>>();\n\n protected override createStore(): JourneyStore {\n // A run fails at its start on an invalid environment, not at the send that needs it\n const { store, close } = host.lease(host.checkEnv(this.env as Env));\n this.leases.set(store, close);\n return store;\n }\n\n protected override async closeStore(store: JourneyStore): Promise<void> {\n await this.leases.get(store)?.();\n }\n\n protected override createRunParts(store: JourneyStore, params: JourneyParams): JourneyRunParts {\n return host.runParts(host.checkEnv(this.hostEnv() as Env), store, params);\n }\n }\n\n /**\n * The validated environment, its Workflow binding behind the host's port (an unknown instance\n * answers `InstanceNotFoundError`). Everything below reads this one, never the raw env.\n */\n const hostEnv = (rawEnv: Env): Env => {\n const env = host.checkEnv(rawEnv);\n return { ...env, JOURNEY: cloudflareJourneyBinding(env.JOURNEY) };\n };\n\n /** CF's version metadata: the bundle compiled into each new version goes live by itself. */\n const ownVersion = (env: Env): BundleVersion | undefined => {\n const metadata = env.CF_VERSION_METADATA;\n if (metadata === undefined) return undefined;\n return { id: metadata.id, createdAt: Date.parse(metadata.timestamp) };\n };\n\n const fetch = async (request: Request, rawEnv: Env, ctx?: ExecutionContextLike) => {\n let env: Env;\n try {\n env = hostEnv(rawEnv);\n } catch (error) {\n if (!(error instanceof EnvValidationError)) throw error;\n console.error(`[workflow] ${error.message}`);\n return new Response(JSON.stringify({ error: error.message }), {\n status: 500,\n headers: { 'content-type': 'application/json' },\n });\n }\n const pending = host.applyOwnBundle(env, ownVersion(env));\n // In the background where the runtime has waitUntil; inline otherwise (tests)\n if (pending !== undefined) {\n if (ctx === undefined) await pending;\n else ctx.waitUntil(pending);\n }\n const { store, close } = host.lease(env);\n try {\n return await host.handle(request, env, store, ownVersion(env));\n } finally {\n // After the response when the runtime offers waitUntil, inline otherwise (tests).\n if (ctx === undefined) await close();\n else ctx.waitUntil(close());\n }\n };\n\n const sweep = (rawEnv: Env) => host.sweep(hostEnv(rawEnv));\n\n const scheduled = async (_controller: unknown, rawEnv: Env, _ctx: ExecutionContextLike) => {\n // Awaited, not handed to waitUntil: the runtime keeps a scheduled handler alive for as long\n // as its promise runs, while a sweep only parked in waitUntil can be cut off by the\n // handler returning, and its failure would surface nowhere. A version with no traffic yet\n // goes live here; the sweep then reads its segments.\n const env = hostEnv(rawEnv);\n await host.applyOwnBundle(env, ownVersion(env));\n await host.sweep(env);\n };\n\n return { Runner, fetch, sweep, scheduled };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAsDA,SAAgB,cACd,SACoB;CACpB,MAAM,OAAOA,0BAAAA,kBAAkB,OAAO;CAEtC,MAAM,eAAeC,0BAAAA,cAAc;EACjC,yBAA0B,IAAI,QAA2C;EAEzE,cAA+C;GAE7C,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,KAAK,SAAS,KAAK,GAAU,CAAC;GAClE,KAAK,OAAO,IAAI,OAAO,KAAK;GAC5B,OAAO;EACT;EAEA,MAAyB,WAAW,OAAoC;GACtE,MAAM,KAAK,OAAO,IAAI,KAAK,CAAC,GAAG;EACjC;EAEA,eAAkC,OAAqB,QAAwC;GAC7F,OAAO,KAAK,SAAS,KAAK,SAAS,KAAK,QAAQ,CAAQ,GAAG,OAAO,MAAM;EAC1E;CACF;;;;;CAMA,MAAM,WAAW,WAAqB;EACpC,MAAM,MAAM,KAAK,SAAS,MAAM;EAChC,OAAO;GAAE,GAAG;GAAK,SAASC,4BAAAA,yBAAyB,IAAI,OAAO;EAAE;CAClE;;CAGA,MAAM,cAAc,QAAwC;EAC1D,MAAM,WAAW,IAAI;EACrB,IAAI,aAAa,KAAA,GAAW,OAAO,KAAA;EACnC,OAAO;GAAE,IAAI,SAAS;GAAI,WAAW,KAAK,MAAM,SAAS,SAAS;EAAE;CACtE;CAEA,MAAM,QAAQ,OAAO,SAAkB,QAAa,QAA+B;EACjF,IAAI;EACJ,IAAI;GACF,MAAM,QAAQ,MAAM;EACtB,SAAS,OAAO;GACd,IAAI,EAAE,iBAAiBC,0BAAAA,qBAAqB,MAAM;GAClD,QAAQ,MAAM,cAAc,MAAM,SAAS;GAC3C,OAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,MAAM,QAAQ,CAAC,GAAG;IAC5D,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;GAChD,CAAC;EACH;EACA,MAAM,UAAU,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAExD,IAAI,YAAY,KAAA,GAAW;GACzB,IAAI,QAAQ,KAAA,GAAW,MAAM;QACxB,IAAI,UAAU,OAAO;EAC5B;EACA,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,GAAG;EACvC,IAAI;GACF,OAAO,MAAM,KAAK,OAAO,SAAS,KAAK,OAAO,WAAW,GAAG,CAAC;EAC/D,UAAU;GAER,IAAI,QAAQ,KAAA,GAAW,MAAM,MAAM;QAC9B,IAAI,UAAU,MAAM,CAAC;EAC5B;CACF;CAEA,MAAM,SAAS,WAAgB,KAAK,MAAM,QAAQ,MAAM,CAAC;CAEzD,MAAM,YAAY,OAAO,aAAsB,QAAa,SAA+B;EAKzF,MAAM,MAAM,QAAQ,MAAM;EAC1B,MAAM,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAC9C,MAAM,KAAK,MAAM,GAAG;CACtB;CAEA,OAAO;EAAE;EAAQ;EAAO;EAAO;CAAU;AAC3C"}
@@ -69,7 +69,7 @@ function journeyWorker(options) {
69
69
  }
70
70
  const { store, close } = host.lease(env);
71
71
  try {
72
- return await host.handle(request, env, store);
72
+ return await host.handle(request, env, store, ownVersion(env));
73
73
  } finally {
74
74
  if (ctx === void 0) await close();
75
75
  else ctx.waitUntil(close());
@@ -1 +1 @@
1
- {"version":3,"file":"worker.mjs","names":[],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import type { JourneyRunParts } from '../host/execute';\nimport type { SweepResult } from '../host/ingest';\nimport {\n EnvValidationError,\n type JourneyHostOptions,\n createJourneyHost,\n} from '../host/journey-host';\nimport type { JourneyEnv, JourneyParams } from '../host/platform';\nimport type { BundleVersion, JourneyStore } from '../store/index';\nimport { cloudflareJourneyBinding } from './bindings';\nimport { JourneyRunner } from './runner';\n\nexport { EnvValidationError } from '../host/journey-host';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/**\n * `journeyWorker`'s options: the host's (config, store, email transport, observer, ...). The\n * email transport is the host's choice — `cloudflareEmailTransport({ binding: env.EMAIL })` for\n * Cloudflare Email, `sesEmailTransport(...)` for SES.\n */\nexport type JourneyWorkerOptions<Env extends JourneyEnv> = JourneyHostOptions<Env>;\n\nexport interface JourneyWorker<Env extends JourneyEnv> {\n /** Export this under the `class_name` of the wrangler `workflows` binding. */\n Runner: new (...args: never[]) => JourneyRunner;\n /** The Worker's fetch handler: `/health`, `/events`, `/identify`, `/devices`, `/messages/opened`, `/segments/sweep`, `/t/*` (email tracking), `/deploy`, `/bundle`, `/bundle/deploy` and `/preview/*`. */\n fetch: (request: Request, env: Env, ctx?: ExecutionContextLike) => Promise<Response>;\n /**\n * The segment sweep (`sweepSegments`): membership of every deployed segment for every known\n * user, the journeys of segments entered by the clock alone (`not(performed(e.login, { within:\n * '7 days' }))` has no event to ride on), and each segment's size. Also served as\n * `POST /segments/sweep`.\n */\n sweep: (env: Env) => Promise<SweepResult>;\n /** The cron trigger's handler: runs the sweep. Export it beside `fetch` with a `triggers.crons` entry. */\n scheduled: (controller: unknown, env: Env, ctx: ExecutionContextLike) => Promise<void>;\n}\n\n/**\n * A complete journey host in one call: the runner class with the app's store,\n * outlets and runtime plugged in, and the fetch handler that serves the ingest\n * API around them.\n *\n * const journeys = journeyWorker<Env>({\n * config, // workflow.config.ts (defineConfig)\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database),\n * });\n * export const MyRunner = journeys.Runner;\n * export default { fetch: journeys.fetch };\n */\nexport function journeyWorker<Env extends JourneyEnv>(\n options: JourneyWorkerOptions<Env>\n): JourneyWorker<Env> {\n const host = createJourneyHost(options);\n\n class Runner extends JourneyRunner {\n private readonly leases = new WeakMap<JourneyStore, () => Promise<void>>();\n\n protected override createStore(): JourneyStore {\n // A run fails at its start on an invalid environment, not at the send that needs it\n const { store, close } = host.lease(host.checkEnv(this.env as Env));\n this.leases.set(store, close);\n return store;\n }\n\n protected override async closeStore(store: JourneyStore): Promise<void> {\n await this.leases.get(store)?.();\n }\n\n protected override createRunParts(store: JourneyStore, params: JourneyParams): JourneyRunParts {\n return host.runParts(host.checkEnv(this.hostEnv() as Env), store, params);\n }\n }\n\n /**\n * The validated environment, its Workflow binding behind the host's port (an unknown instance\n * answers `InstanceNotFoundError`). Everything below reads this one, never the raw env.\n */\n const hostEnv = (rawEnv: Env): Env => {\n const env = host.checkEnv(rawEnv);\n return { ...env, JOURNEY: cloudflareJourneyBinding(env.JOURNEY) };\n };\n\n /** CF's version metadata: the bundle compiled into each new version goes live by itself. */\n const ownVersion = (env: Env): BundleVersion | undefined => {\n const metadata = env.CF_VERSION_METADATA;\n if (metadata === undefined) return undefined;\n return { id: metadata.id, createdAt: Date.parse(metadata.timestamp) };\n };\n\n const fetch = async (request: Request, rawEnv: Env, ctx?: ExecutionContextLike) => {\n let env: Env;\n try {\n env = hostEnv(rawEnv);\n } catch (error) {\n if (!(error instanceof EnvValidationError)) throw error;\n console.error(`[workflow] ${error.message}`);\n return new Response(JSON.stringify({ error: error.message }), {\n status: 500,\n headers: { 'content-type': 'application/json' },\n });\n }\n const pending = host.applyOwnBundle(env, ownVersion(env));\n // In the background where the runtime has waitUntil; inline otherwise (tests)\n if (pending !== undefined) {\n if (ctx === undefined) await pending;\n else ctx.waitUntil(pending);\n }\n const { store, close } = host.lease(env);\n try {\n return await host.handle(request, env, store);\n } finally {\n // After the response when the runtime offers waitUntil, inline otherwise (tests).\n if (ctx === undefined) await close();\n else ctx.waitUntil(close());\n }\n };\n\n const sweep = (rawEnv: Env) => host.sweep(hostEnv(rawEnv));\n\n const scheduled = async (_controller: unknown, rawEnv: Env, _ctx: ExecutionContextLike) => {\n // Awaited, not handed to waitUntil: the runtime keeps a scheduled handler alive for as long\n // as its promise runs, while a sweep only parked in waitUntil can be cut off by the\n // handler returning, and its failure would surface nowhere. A version with no traffic yet\n // goes live here; the sweep then reads its segments.\n const env = hostEnv(rawEnv);\n await host.applyOwnBundle(env, ownVersion(env));\n await host.sweep(env);\n };\n\n return { Runner, fetch, sweep, scheduled };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAsDA,SAAgB,cACd,SACoB;CACpB,MAAM,OAAO,kBAAkB,OAAO;CAEtC,MAAM,eAAe,cAAc;EACjC,yBAA0B,IAAI,QAA2C;EAEzE,cAA+C;GAE7C,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,KAAK,SAAS,KAAK,GAAU,CAAC;GAClE,KAAK,OAAO,IAAI,OAAO,KAAK;GAC5B,OAAO;EACT;EAEA,MAAyB,WAAW,OAAoC;GACtE,MAAM,KAAK,OAAO,IAAI,KAAK,CAAC,GAAG;EACjC;EAEA,eAAkC,OAAqB,QAAwC;GAC7F,OAAO,KAAK,SAAS,KAAK,SAAS,KAAK,QAAQ,CAAQ,GAAG,OAAO,MAAM;EAC1E;CACF;;;;;CAMA,MAAM,WAAW,WAAqB;EACpC,MAAM,MAAM,KAAK,SAAS,MAAM;EAChC,OAAO;GAAE,GAAG;GAAK,SAAS,yBAAyB,IAAI,OAAO;EAAE;CAClE;;CAGA,MAAM,cAAc,QAAwC;EAC1D,MAAM,WAAW,IAAI;EACrB,IAAI,aAAa,KAAA,GAAW,OAAO,KAAA;EACnC,OAAO;GAAE,IAAI,SAAS;GAAI,WAAW,KAAK,MAAM,SAAS,SAAS;EAAE;CACtE;CAEA,MAAM,QAAQ,OAAO,SAAkB,QAAa,QAA+B;EACjF,IAAI;EACJ,IAAI;GACF,MAAM,QAAQ,MAAM;EACtB,SAAS,OAAO;GACd,IAAI,EAAE,iBAAiB,qBAAqB,MAAM;GAClD,QAAQ,MAAM,cAAc,MAAM,SAAS;GAC3C,OAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,MAAM,QAAQ,CAAC,GAAG;IAC5D,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;GAChD,CAAC;EACH;EACA,MAAM,UAAU,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAExD,IAAI,YAAY,KAAA,GAAW;GACzB,IAAI,QAAQ,KAAA,GAAW,MAAM;QACxB,IAAI,UAAU,OAAO;EAC5B;EACA,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,GAAG;EACvC,IAAI;GACF,OAAO,MAAM,KAAK,OAAO,SAAS,KAAK,KAAK;EAC9C,UAAU;GAER,IAAI,QAAQ,KAAA,GAAW,MAAM,MAAM;QAC9B,IAAI,UAAU,MAAM,CAAC;EAC5B;CACF;CAEA,MAAM,SAAS,WAAgB,KAAK,MAAM,QAAQ,MAAM,CAAC;CAEzD,MAAM,YAAY,OAAO,aAAsB,QAAa,SAA+B;EAKzF,MAAM,MAAM,QAAQ,MAAM;EAC1B,MAAM,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAC9C,MAAM,KAAK,MAAM,GAAG;CACtB;CAEA,OAAO;EAAE;EAAQ;EAAO;EAAO;CAAU;AAC3C"}
1
+ {"version":3,"file":"worker.mjs","names":[],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import type { JourneyRunParts } from '../host/execute';\nimport type { SweepResult } from '../host/ingest';\nimport {\n EnvValidationError,\n type JourneyHostOptions,\n createJourneyHost,\n} from '../host/journey-host';\nimport type { JourneyEnv, JourneyParams } from '../host/platform';\nimport type { BundleVersion, JourneyStore } from '../store/index';\nimport { cloudflareJourneyBinding } from './bindings';\nimport { JourneyRunner } from './runner';\n\nexport { EnvValidationError } from '../host/journey-host';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/**\n * `journeyWorker`'s options: the host's (config, store, email transport, observer, ...). The\n * email transport is the host's choice — `cloudflareEmailTransport({ binding: env.EMAIL })` for\n * Cloudflare Email, `sesEmailTransport(...)` for SES.\n */\nexport type JourneyWorkerOptions<Env extends JourneyEnv> = JourneyHostOptions<Env>;\n\nexport interface JourneyWorker<Env extends JourneyEnv> {\n /** Export this under the `class_name` of the wrangler `workflows` binding. */\n Runner: new (...args: never[]) => JourneyRunner;\n /** The Worker's fetch handler: `/health`, `/events`, `/identify`, `/devices`, `/messages/opened`, `/segments/sweep`, `/t/*` (email tracking), `/deploy`, `/bundle`, `/bundle/deploy` and `/preview/*`. */\n fetch: (request: Request, env: Env, ctx?: ExecutionContextLike) => Promise<Response>;\n /**\n * The segment sweep (`sweepSegments`): membership of every deployed segment for every known\n * user, the journeys of segments entered by the clock alone (`not(performed(e.login, { within:\n * '7 days' }))` has no event to ride on), and each segment's size. Also served as\n * `POST /segments/sweep`.\n */\n sweep: (env: Env) => Promise<SweepResult>;\n /** The cron trigger's handler: runs the sweep. Export it beside `fetch` with a `triggers.crons` entry. */\n scheduled: (controller: unknown, env: Env, ctx: ExecutionContextLike) => Promise<void>;\n}\n\n/**\n * A complete journey host in one call: the runner class with the app's store,\n * outlets and runtime plugged in, and the fetch handler that serves the ingest\n * API around them.\n *\n * const journeys = journeyWorker<Env>({\n * config, // workflow.config.ts (defineConfig)\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database),\n * });\n * export const MyRunner = journeys.Runner;\n * export default { fetch: journeys.fetch };\n */\nexport function journeyWorker<Env extends JourneyEnv>(\n options: JourneyWorkerOptions<Env>\n): JourneyWorker<Env> {\n const host = createJourneyHost(options);\n\n class Runner extends JourneyRunner {\n private readonly leases = new WeakMap<JourneyStore, () => Promise<void>>();\n\n protected override createStore(): JourneyStore {\n // A run fails at its start on an invalid environment, not at the send that needs it\n const { store, close } = host.lease(host.checkEnv(this.env as Env));\n this.leases.set(store, close);\n return store;\n }\n\n protected override async closeStore(store: JourneyStore): Promise<void> {\n await this.leases.get(store)?.();\n }\n\n protected override createRunParts(store: JourneyStore, params: JourneyParams): JourneyRunParts {\n return host.runParts(host.checkEnv(this.hostEnv() as Env), store, params);\n }\n }\n\n /**\n * The validated environment, its Workflow binding behind the host's port (an unknown instance\n * answers `InstanceNotFoundError`). Everything below reads this one, never the raw env.\n */\n const hostEnv = (rawEnv: Env): Env => {\n const env = host.checkEnv(rawEnv);\n return { ...env, JOURNEY: cloudflareJourneyBinding(env.JOURNEY) };\n };\n\n /** CF's version metadata: the bundle compiled into each new version goes live by itself. */\n const ownVersion = (env: Env): BundleVersion | undefined => {\n const metadata = env.CF_VERSION_METADATA;\n if (metadata === undefined) return undefined;\n return { id: metadata.id, createdAt: Date.parse(metadata.timestamp) };\n };\n\n const fetch = async (request: Request, rawEnv: Env, ctx?: ExecutionContextLike) => {\n let env: Env;\n try {\n env = hostEnv(rawEnv);\n } catch (error) {\n if (!(error instanceof EnvValidationError)) throw error;\n console.error(`[workflow] ${error.message}`);\n return new Response(JSON.stringify({ error: error.message }), {\n status: 500,\n headers: { 'content-type': 'application/json' },\n });\n }\n const pending = host.applyOwnBundle(env, ownVersion(env));\n // In the background where the runtime has waitUntil; inline otherwise (tests)\n if (pending !== undefined) {\n if (ctx === undefined) await pending;\n else ctx.waitUntil(pending);\n }\n const { store, close } = host.lease(env);\n try {\n return await host.handle(request, env, store, ownVersion(env));\n } finally {\n // After the response when the runtime offers waitUntil, inline otherwise (tests).\n if (ctx === undefined) await close();\n else ctx.waitUntil(close());\n }\n };\n\n const sweep = (rawEnv: Env) => host.sweep(hostEnv(rawEnv));\n\n const scheduled = async (_controller: unknown, rawEnv: Env, _ctx: ExecutionContextLike) => {\n // Awaited, not handed to waitUntil: the runtime keeps a scheduled handler alive for as long\n // as its promise runs, while a sweep only parked in waitUntil can be cut off by the\n // handler returning, and its failure would surface nowhere. A version with no traffic yet\n // goes live here; the sweep then reads its segments.\n const env = hostEnv(rawEnv);\n await host.applyOwnBundle(env, ownVersion(env));\n await host.sweep(env);\n };\n\n return { Runner, fetch, sweep, scheduled };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAsDA,SAAgB,cACd,SACoB;CACpB,MAAM,OAAO,kBAAkB,OAAO;CAEtC,MAAM,eAAe,cAAc;EACjC,yBAA0B,IAAI,QAA2C;EAEzE,cAA+C;GAE7C,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,KAAK,SAAS,KAAK,GAAU,CAAC;GAClE,KAAK,OAAO,IAAI,OAAO,KAAK;GAC5B,OAAO;EACT;EAEA,MAAyB,WAAW,OAAoC;GACtE,MAAM,KAAK,OAAO,IAAI,KAAK,CAAC,GAAG;EACjC;EAEA,eAAkC,OAAqB,QAAwC;GAC7F,OAAO,KAAK,SAAS,KAAK,SAAS,KAAK,QAAQ,CAAQ,GAAG,OAAO,MAAM;EAC1E;CACF;;;;;CAMA,MAAM,WAAW,WAAqB;EACpC,MAAM,MAAM,KAAK,SAAS,MAAM;EAChC,OAAO;GAAE,GAAG;GAAK,SAAS,yBAAyB,IAAI,OAAO;EAAE;CAClE;;CAGA,MAAM,cAAc,QAAwC;EAC1D,MAAM,WAAW,IAAI;EACrB,IAAI,aAAa,KAAA,GAAW,OAAO,KAAA;EACnC,OAAO;GAAE,IAAI,SAAS;GAAI,WAAW,KAAK,MAAM,SAAS,SAAS;EAAE;CACtE;CAEA,MAAM,QAAQ,OAAO,SAAkB,QAAa,QAA+B;EACjF,IAAI;EACJ,IAAI;GACF,MAAM,QAAQ,MAAM;EACtB,SAAS,OAAO;GACd,IAAI,EAAE,iBAAiB,qBAAqB,MAAM;GAClD,QAAQ,MAAM,cAAc,MAAM,SAAS;GAC3C,OAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,MAAM,QAAQ,CAAC,GAAG;IAC5D,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;GAChD,CAAC;EACH;EACA,MAAM,UAAU,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAExD,IAAI,YAAY,KAAA,GAAW;GACzB,IAAI,QAAQ,KAAA,GAAW,MAAM;QACxB,IAAI,UAAU,OAAO;EAC5B;EACA,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,GAAG;EACvC,IAAI;GACF,OAAO,MAAM,KAAK,OAAO,SAAS,KAAK,OAAO,WAAW,GAAG,CAAC;EAC/D,UAAU;GAER,IAAI,QAAQ,KAAA,GAAW,MAAM,MAAM;QAC9B,IAAI,UAAU,MAAM,CAAC;EAC5B;CACF;CAEA,MAAM,SAAS,WAAgB,KAAK,MAAM,QAAQ,MAAM,CAAC;CAEzD,MAAM,YAAY,OAAO,aAAsB,QAAa,SAA+B;EAKzF,MAAM,MAAM,QAAQ,MAAM;EAC1B,MAAM,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAC9C,MAAM,KAAK,MAAM,GAAG;CACtB;CAEA,OAAO;EAAE;EAAQ;EAAO;EAAO;CAAU;AAC3C"}
@@ -13,7 +13,14 @@ var EmailSender = class {
13
13
  }
14
14
  async send(message) {
15
15
  if (message.channel !== "email") throw new Error(`EmailSender: unsupported channel '${message.channel}'`);
16
- if (message.recipient === void 0) throw new Error(`EmailSender: no recipient for user '${message.userId}'`);
16
+ if (message.recipient === void 0) return {
17
+ delivered: false,
18
+ reason: "no_recipient"
19
+ };
20
+ if (!require_email.isEmailAddress(message.recipient)) return {
21
+ delivered: false,
22
+ reason: "invalid_recipient"
23
+ };
17
24
  const messageId = message.tracking?.messageId;
18
25
  const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props, messageId === void 0 ? {} : { messageId });
19
26
  const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
@@ -1 +1 @@
1
- {"version":3,"file":"email-sender.cjs","names":["isUnsubscribeLink"],"sources":["../src/email-sender.ts"],"sourcesContent":["import {\n type EmailAddress,\n type EmailRenderOptions,\n type ResolvedEmail,\n isUnsubscribeLink,\n} from './email';\nimport type { MessageSender, MessageTracking, OutboundMessage } from './engine/ports';\nimport type { ScalarIR } from './ir';\n\n/**\n * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,\n * applies its tracking and commits the tracked links — the same on every host — and hands the\n * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare\n * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.\n */\n\n/** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */\nexport interface RenderedEmail extends ResolvedEmail {\n subject: string;\n html: string;\n}\n\n/**\n * Template renderer, injected by the app — this package's core stays free of\n * any react-email dependency (`@shware/workflow/react-email` ships one over a\n * registry). The subject comes back final: it is sent exactly as written, from\n * the template's own sender, and the unsubscribe link is the one the HTML was\n * rendered with. `options` carries the copy's message id when reporting records one, for the\n * preferences link (`resolveEmail`).\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>,\n options?: EmailRenderOptions\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an\n * email that has an unsubscribe link (see `unsubscribeHeaders`).\n */\n headers?: Readonly<Record<string, string>>;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`EmailSender: no recipient for user '${message.userId}'`);\n }\n const messageId = message.tracking?.messageId;\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props,\n messageId === undefined ? {} : { messageId }\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n await message.tracking?.commit?.();\n const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);\n await this.options.transport.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n ...(headers === undefined ? {} : { headers }),\n idempotencyKey: message.idempotencyKey,\n });\n }\n}\n\n/**\n * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a\n * mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:\n *\n * - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the\n * header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.\n * - Without one: the header names the email's own unsubscribe link, with no one-click claim —\n * whether that page takes a POST is the app's.\n * - An email without an unsubscribe link (a transactional one) gets neither.\n *\n * Only an https URL is named: mail clients and Cloudflare Email refuse any other.\n */\nexport function unsubscribeHeaders(\n unsubscribeUrl: string | undefined,\n oneClick: string | undefined\n): Record<string, string> | undefined {\n if (unsubscribeUrl === undefined) return undefined;\n const https = (url: string | undefined) => url?.startsWith('https://') === true;\n if (https(oneClick)) {\n return {\n 'List-Unsubscribe': `<${oneClick}>`,\n 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n };\n }\n if (https(unsubscribeUrl)) return { 'List-Unsubscribe': `<${unsubscribeUrl}>` };\n return undefined;\n}\n\nconst LINK = /(<a\\b[^>]*?\\shref\\s*=\\s*)([\"'])(https?:\\/\\/[^\"']+)\\2/gi;\n\n/**\n * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through\n * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —\n * as the unsubscribe link when it is the one the email was rendered with, so what a click on it\n * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the\n * end when there is none.\n */\nexport function trackEmail(\n html: string,\n tracking: MessageTracking,\n unsubscribeUrl?: string\n): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(LINK, (_, start: string, quote: string, attribute: string) => {\n const url = unescapeAttribute(attribute);\n const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? 'unsubscribe' : 'link';\n return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;\n });\n }\n if (pixel !== undefined) {\n const image = `<img src=\"${escapeAttribute(pixel)}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;border:0;width:1px;height:1px\" />`;\n out = /<\\/body>/i.test(out) ? out.replace(/<\\/body>/i, `${image}</body>`) : `${out}${image}`;\n }\n return out;\n}\n\nfunction unescapeAttribute(value: string): string {\n return value.replaceAll('&amp;', '&').replaceAll('&quot;', '\"').replaceAll('&#x27;', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&amp;').replaceAll('\"', '&quot;');\n}\n"],"mappings":";;;;;;;;AA0EA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAyC;EAClD,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAEzE,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,uCAAuC,QAAQ,OAAO,EAAE;EAE1E,MAAM,YAAY,QAAQ,UAAU;EACpC,MAAM,EAAE,SAAS,MAAM,MAAM,SAAS,mBAAmB,MAAM,KAAK,QAAQ,OAC1E,QAAQ,UACR,QAAQ,OACR,cAAc,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU,CAC7C;EACA,MAAM,UACJ,QAAQ,aAAa,KAAA,IAAY,OAAO,WAAW,MAAM,QAAQ,UAAU,cAAc;EAC3F,MAAM,QAAQ,UAAU,SAAS;EACjC,MAAM,UAAU,mBAAmB,gBAAgB,QAAQ,UAAU,WAAW;EAChF,MAAM,KAAK,QAAQ,UAAU,KAAK;GAChC;GACA,IAAI,QAAQ;GACZ,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C;GACA,MAAM;GACN,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C,gBAAgB,QAAQ;EAC1B,CAAC;CACH;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,gBACA,UACoC;CACpC,IAAI,mBAAmB,KAAA,GAAW,OAAO,KAAA;CACzC,MAAM,SAAS,QAA4B,KAAK,WAAW,UAAU,MAAM;CAC3E,IAAI,MAAM,QAAQ,GAChB,OAAO;EACL,oBAAoB,IAAI,SAAS;EACjC,yBAAyB;CAC3B;CAEF,IAAI,MAAM,cAAc,GAAG,OAAO,EAAE,oBAAoB,IAAI,eAAe,GAAG;AAEhF;AAEA,MAAM,OAAO;;;;;;;;AASb,SAAgB,WACd,MACA,UACA,gBACQ;CACR,MAAM,EAAE,MAAM,UAAU;CACxB,IAAI,MAAM;CACV,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,QAAQ,OAAO,GAAG,OAAe,OAAe,cAAsB;EAC9E,MAAM,MAAM,kBAAkB,SAAS;EACvC,MAAM,UAAUA,cAAAA,kBAAkB,KAAK,cAAc,IAAI,gBAAgB;EACzE,OAAO,GAAG,QAAQ,QAAQ,gBAAgB,KAAK,KAAK,OAAO,CAAC,IAAI;CAClE,CAAC;CAEH,IAAI,UAAU,KAAA,GAAW;EACvB,MAAM,QAAQ,aAAa,gBAAgB,KAAK,EAAE;EAClD,MAAM,YAAY,KAAK,GAAG,IAAI,IAAI,QAAQ,aAAa,GAAG,MAAM,QAAQ,IAAI,GAAG,MAAM;CACvF;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,OAAuB;CAChD,OAAO,MAAM,WAAW,SAAS,GAAG,CAAC,CAAC,WAAW,UAAU,IAAG,CAAC,CAAC,WAAW,UAAU,GAAG;AAC1F;AAEA,SAAS,gBAAgB,OAAuB;CAC9C,OAAO,MAAM,WAAW,KAAK,OAAO,CAAC,CAAC,WAAW,MAAK,QAAQ;AAChE"}
1
+ {"version":3,"file":"email-sender.cjs","names":["isEmailAddress","isUnsubscribeLink"],"sources":["../src/email-sender.ts"],"sourcesContent":["import {\n type EmailAddress,\n type EmailRenderOptions,\n type ResolvedEmail,\n isEmailAddress,\n isUnsubscribeLink,\n} from './email';\nimport type { MessageSender, MessageTracking, OutboundMessage, SendResult } from './engine/ports';\nimport type { ScalarIR } from './ir';\n\n/**\n * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,\n * applies its tracking and commits the tracked links — the same on every host — and hands the\n * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare\n * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.\n */\n\n/** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */\nexport interface RenderedEmail extends ResolvedEmail {\n subject: string;\n html: string;\n}\n\n/**\n * Template renderer, injected by the app — this package's core stays free of\n * any react-email dependency (`@shware/workflow/react-email` ships one over a\n * registry). The subject comes back final: it is sent exactly as written, from\n * the template's own sender, and the unsubscribe link is the one the HTML was\n * rendered with. `options` carries the copy's message id when reporting records one, for the\n * preferences link (`resolveEmail`).\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>,\n options?: EmailRenderOptions\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an\n * email that has an unsubscribe link (see `unsubscribeHeaders`).\n */\n headers?: Readonly<Record<string, string>>;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n // A user with no usable address is a fact about that user, not a fault worth retrying: the\n // copy settles undelivered and the journey goes on, the way a push with no device is skipped.\n // Throwing here burned the step's retries and then failed the whole instance.\n if (message.recipient === undefined) return { delivered: false, reason: 'no_recipient' };\n if (!isEmailAddress(message.recipient)) {\n return { delivered: false, reason: 'invalid_recipient' };\n }\n const messageId = message.tracking?.messageId;\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props,\n messageId === undefined ? {} : { messageId }\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n await message.tracking?.commit?.();\n const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);\n await this.options.transport.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n ...(headers === undefined ? {} : { headers }),\n idempotencyKey: message.idempotencyKey,\n });\n }\n}\n\n/**\n * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a\n * mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:\n *\n * - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the\n * header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.\n * - Without one: the header names the email's own unsubscribe link, with no one-click claim —\n * whether that page takes a POST is the app's.\n * - An email without an unsubscribe link (a transactional one) gets neither.\n *\n * Only an https URL is named: mail clients and Cloudflare Email refuse any other.\n */\nexport function unsubscribeHeaders(\n unsubscribeUrl: string | undefined,\n oneClick: string | undefined\n): Record<string, string> | undefined {\n if (unsubscribeUrl === undefined) return undefined;\n const https = (url: string | undefined) => url?.startsWith('https://') === true;\n if (https(oneClick)) {\n return {\n 'List-Unsubscribe': `<${oneClick}>`,\n 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n };\n }\n if (https(unsubscribeUrl)) return { 'List-Unsubscribe': `<${unsubscribeUrl}>` };\n return undefined;\n}\n\nconst LINK = /(<a\\b[^>]*?\\shref\\s*=\\s*)([\"'])(https?:\\/\\/[^\"']+)\\2/gi;\n\n/**\n * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through\n * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —\n * as the unsubscribe link when it is the one the email was rendered with, so what a click on it\n * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the\n * end when there is none.\n */\nexport function trackEmail(\n html: string,\n tracking: MessageTracking,\n unsubscribeUrl?: string\n): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(LINK, (_, start: string, quote: string, attribute: string) => {\n const url = unescapeAttribute(attribute);\n const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? 'unsubscribe' : 'link';\n return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;\n });\n }\n if (pixel !== undefined) {\n const image = `<img src=\"${escapeAttribute(pixel)}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;border:0;width:1px;height:1px\" />`;\n out = /<\\/body>/i.test(out) ? out.replace(/<\\/body>/i, `${image}</body>`) : `${out}${image}`;\n }\n return out;\n}\n\nfunction unescapeAttribute(value: string): string {\n return value.replaceAll('&amp;', '&').replaceAll('&quot;', '\"').replaceAll('&#x27;', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&amp;').replaceAll('\"', '&quot;');\n}\n"],"mappings":";;;;;;;;AA2EA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAsD;EAC/D,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAKzE,IAAI,QAAQ,cAAc,KAAA,GAAW,OAAO;GAAE,WAAW;GAAO,QAAQ;EAAe;EACvF,IAAI,CAACA,cAAAA,eAAe,QAAQ,SAAS,GACnC,OAAO;GAAE,WAAW;GAAO,QAAQ;EAAoB;EAEzD,MAAM,YAAY,QAAQ,UAAU;EACpC,MAAM,EAAE,SAAS,MAAM,MAAM,SAAS,mBAAmB,MAAM,KAAK,QAAQ,OAC1E,QAAQ,UACR,QAAQ,OACR,cAAc,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU,CAC7C;EACA,MAAM,UACJ,QAAQ,aAAa,KAAA,IAAY,OAAO,WAAW,MAAM,QAAQ,UAAU,cAAc;EAC3F,MAAM,QAAQ,UAAU,SAAS;EACjC,MAAM,UAAU,mBAAmB,gBAAgB,QAAQ,UAAU,WAAW;EAChF,MAAM,KAAK,QAAQ,UAAU,KAAK;GAChC;GACA,IAAI,QAAQ;GACZ,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C;GACA,MAAM;GACN,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C,gBAAgB,QAAQ;EAC1B,CAAC;CACH;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,gBACA,UACoC;CACpC,IAAI,mBAAmB,KAAA,GAAW,OAAO,KAAA;CACzC,MAAM,SAAS,QAA4B,KAAK,WAAW,UAAU,MAAM;CAC3E,IAAI,MAAM,QAAQ,GAChB,OAAO;EACL,oBAAoB,IAAI,SAAS;EACjC,yBAAyB;CAC3B;CAEF,IAAI,MAAM,cAAc,GAAG,OAAO,EAAE,oBAAoB,IAAI,eAAe,GAAG;AAEhF;AAEA,MAAM,OAAO;;;;;;;;AASb,SAAgB,WACd,MACA,UACA,gBACQ;CACR,MAAM,EAAE,MAAM,UAAU;CACxB,IAAI,MAAM;CACV,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,QAAQ,OAAO,GAAG,OAAe,OAAe,cAAsB;EAC9E,MAAM,MAAM,kBAAkB,SAAS;EACvC,MAAM,UAAUC,cAAAA,kBAAkB,KAAK,cAAc,IAAI,gBAAgB;EACzE,OAAO,GAAG,QAAQ,QAAQ,gBAAgB,KAAK,KAAK,OAAO,CAAC,IAAI;CAClE,CAAC;CAEH,IAAI,UAAU,KAAA,GAAW;EACvB,MAAM,QAAQ,aAAa,gBAAgB,KAAK,EAAE;EAClD,MAAM,YAAY,KAAK,GAAG,IAAI,IAAI,QAAQ,aAAa,GAAG,MAAM,QAAQ,IAAI,GAAG,MAAM;CACvF;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,OAAuB;CAChD,OAAO,MAAM,WAAW,SAAS,GAAG,CAAC,CAAC,WAAW,UAAU,IAAG,CAAC,CAAC,WAAW,UAAU,GAAG;AAC1F;AAEA,SAAS,gBAAgB,OAAuB;CAC9C,OAAO,MAAM,WAAW,KAAK,OAAO,CAAC,CAAC,WAAW,MAAK,QAAQ;AAChE"}
@@ -1,5 +1,5 @@
1
1
  import { ScalarIR } from "./ir.cjs";
2
- import { MessageSender, MessageTracking, OutboundMessage } from "./engine/ports.cjs";
2
+ import { MessageSender, MessageTracking, OutboundMessage, SendResult } from "./engine/ports.cjs";
3
3
  import { EmailAddress, EmailRenderOptions, ResolvedEmail } from "./email.cjs";
4
4
  /**
5
5
  * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,
@@ -61,7 +61,7 @@ export interface EmailSenderOptions {
61
61
  export declare class EmailSender implements MessageSender {
62
62
  private readonly options;
63
63
  constructor(options: EmailSenderOptions);
64
- send(message: OutboundMessage): Promise<void>;
64
+ send(message: OutboundMessage): Promise<void | SendResult>;
65
65
  }
66
66
  /**
67
67
  * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a
@@ -1 +1 @@
1
- {"version":3,"file":"email-sender.d.cts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;iBAiBiB,sBAAsB;EACrC;EACA;;;;;;;;;;YAWU,iBACV,kBACA,OAAO,eAAe,uBACtB,UAAU,uBACP,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;EAKA,UAAU,SAAS;;;;;;EAMnB;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;;;;;;wBAyCxB,mBACd,oCACA,+BACC;;;;;;;;wBAsBa,WACd,cACA,UAAU,iBACV"}
1
+ {"version":3,"file":"email-sender.d.cts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;iBAkBiB,sBAAsB;EACrC;EACA;;;;;;;;;;YAWU,iBACV,kBACA,OAAO,eAAe,uBACtB,UAAU,uBACP,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;EAKA,UAAU,SAAS;;;;;;EAMnB;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;;;;;;wBA6CvC,mBACd,oCACA,+BACC;;;;;;;;wBAsBa,WACd,cACA,UAAU,iBACV"}
@@ -1,5 +1,5 @@
1
1
  import { ScalarIR } from "./ir.mjs";
2
- import { MessageSender, MessageTracking, OutboundMessage } from "./engine/ports.mjs";
2
+ import { MessageSender, MessageTracking, OutboundMessage, SendResult } from "./engine/ports.mjs";
3
3
  import { EmailAddress, EmailRenderOptions, ResolvedEmail } from "./email.mjs";
4
4
  /**
5
5
  * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,
@@ -61,7 +61,7 @@ export interface EmailSenderOptions {
61
61
  export declare class EmailSender implements MessageSender {
62
62
  private readonly options;
63
63
  constructor(options: EmailSenderOptions);
64
- send(message: OutboundMessage): Promise<void>;
64
+ send(message: OutboundMessage): Promise<void | SendResult>;
65
65
  }
66
66
  /**
67
67
  * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a
@@ -1 +1 @@
1
- {"version":3,"file":"email-sender.d.mts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;iBAiBiB,sBAAsB;EACrC;EACA;;;;;;;;;;YAWU,iBACV,kBACA,OAAO,eAAe,uBACtB,UAAU,uBACP,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;EAKA,UAAU,SAAS;;;;;;EAMnB;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;;;;;;wBAyCxB,mBACd,oCACA,+BACC;;;;;;;;wBAsBa,WACd,cACA,UAAU,iBACV"}
1
+ {"version":3,"file":"email-sender.d.mts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;iBAkBiB,sBAAsB;EACrC;EACA;;;;;;;;;;YAWU,iBACV,kBACA,OAAO,eAAe,uBACtB,UAAU,uBACP,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;EAKA,UAAU,SAAS;;;;;;EAMnB;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;;;;;;wBA6CvC,mBACd,oCACA,+BACC;;;;;;;;wBAsBa,WACd,cACA,UAAU,iBACV"}
@@ -1,4 +1,4 @@
1
- import { isUnsubscribeLink } from "./email.mjs";
1
+ import { isEmailAddress, isUnsubscribeLink } from "./email.mjs";
2
2
  // src/email-sender.ts
3
3
  /**
4
4
  * The email channel over any transport: render → tracking applied to the HTML → the tracked
@@ -12,7 +12,14 @@ var EmailSender = class {
12
12
  }
13
13
  async send(message) {
14
14
  if (message.channel !== "email") throw new Error(`EmailSender: unsupported channel '${message.channel}'`);
15
- if (message.recipient === void 0) throw new Error(`EmailSender: no recipient for user '${message.userId}'`);
15
+ if (message.recipient === void 0) return {
16
+ delivered: false,
17
+ reason: "no_recipient"
18
+ };
19
+ if (!isEmailAddress(message.recipient)) return {
20
+ delivered: false,
21
+ reason: "invalid_recipient"
22
+ };
16
23
  const messageId = message.tracking?.messageId;
17
24
  const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props, messageId === void 0 ? {} : { messageId });
18
25
  const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
@@ -1 +1 @@
1
- {"version":3,"file":"email-sender.mjs","names":[],"sources":["../src/email-sender.ts"],"sourcesContent":["import {\n type EmailAddress,\n type EmailRenderOptions,\n type ResolvedEmail,\n isUnsubscribeLink,\n} from './email';\nimport type { MessageSender, MessageTracking, OutboundMessage } from './engine/ports';\nimport type { ScalarIR } from './ir';\n\n/**\n * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,\n * applies its tracking and commits the tracked links — the same on every host — and hands the\n * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare\n * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.\n */\n\n/** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */\nexport interface RenderedEmail extends ResolvedEmail {\n subject: string;\n html: string;\n}\n\n/**\n * Template renderer, injected by the app — this package's core stays free of\n * any react-email dependency (`@shware/workflow/react-email` ships one over a\n * registry). The subject comes back final: it is sent exactly as written, from\n * the template's own sender, and the unsubscribe link is the one the HTML was\n * rendered with. `options` carries the copy's message id when reporting records one, for the\n * preferences link (`resolveEmail`).\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>,\n options?: EmailRenderOptions\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an\n * email that has an unsubscribe link (see `unsubscribeHeaders`).\n */\n headers?: Readonly<Record<string, string>>;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`EmailSender: no recipient for user '${message.userId}'`);\n }\n const messageId = message.tracking?.messageId;\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props,\n messageId === undefined ? {} : { messageId }\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n await message.tracking?.commit?.();\n const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);\n await this.options.transport.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n ...(headers === undefined ? {} : { headers }),\n idempotencyKey: message.idempotencyKey,\n });\n }\n}\n\n/**\n * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a\n * mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:\n *\n * - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the\n * header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.\n * - Without one: the header names the email's own unsubscribe link, with no one-click claim —\n * whether that page takes a POST is the app's.\n * - An email without an unsubscribe link (a transactional one) gets neither.\n *\n * Only an https URL is named: mail clients and Cloudflare Email refuse any other.\n */\nexport function unsubscribeHeaders(\n unsubscribeUrl: string | undefined,\n oneClick: string | undefined\n): Record<string, string> | undefined {\n if (unsubscribeUrl === undefined) return undefined;\n const https = (url: string | undefined) => url?.startsWith('https://') === true;\n if (https(oneClick)) {\n return {\n 'List-Unsubscribe': `<${oneClick}>`,\n 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n };\n }\n if (https(unsubscribeUrl)) return { 'List-Unsubscribe': `<${unsubscribeUrl}>` };\n return undefined;\n}\n\nconst LINK = /(<a\\b[^>]*?\\shref\\s*=\\s*)([\"'])(https?:\\/\\/[^\"']+)\\2/gi;\n\n/**\n * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through\n * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —\n * as the unsubscribe link when it is the one the email was rendered with, so what a click on it\n * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the\n * end when there is none.\n */\nexport function trackEmail(\n html: string,\n tracking: MessageTracking,\n unsubscribeUrl?: string\n): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(LINK, (_, start: string, quote: string, attribute: string) => {\n const url = unescapeAttribute(attribute);\n const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? 'unsubscribe' : 'link';\n return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;\n });\n }\n if (pixel !== undefined) {\n const image = `<img src=\"${escapeAttribute(pixel)}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;border:0;width:1px;height:1px\" />`;\n out = /<\\/body>/i.test(out) ? out.replace(/<\\/body>/i, `${image}</body>`) : `${out}${image}`;\n }\n return out;\n}\n\nfunction unescapeAttribute(value: string): string {\n return value.replaceAll('&amp;', '&').replaceAll('&quot;', '\"').replaceAll('&#x27;', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&amp;').replaceAll('\"', '&quot;');\n}\n"],"mappings":";;;;;;;AA0EA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAyC;EAClD,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAEzE,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,uCAAuC,QAAQ,OAAO,EAAE;EAE1E,MAAM,YAAY,QAAQ,UAAU;EACpC,MAAM,EAAE,SAAS,MAAM,MAAM,SAAS,mBAAmB,MAAM,KAAK,QAAQ,OAC1E,QAAQ,UACR,QAAQ,OACR,cAAc,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU,CAC7C;EACA,MAAM,UACJ,QAAQ,aAAa,KAAA,IAAY,OAAO,WAAW,MAAM,QAAQ,UAAU,cAAc;EAC3F,MAAM,QAAQ,UAAU,SAAS;EACjC,MAAM,UAAU,mBAAmB,gBAAgB,QAAQ,UAAU,WAAW;EAChF,MAAM,KAAK,QAAQ,UAAU,KAAK;GAChC;GACA,IAAI,QAAQ;GACZ,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C;GACA,MAAM;GACN,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C,gBAAgB,QAAQ;EAC1B,CAAC;CACH;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,gBACA,UACoC;CACpC,IAAI,mBAAmB,KAAA,GAAW,OAAO,KAAA;CACzC,MAAM,SAAS,QAA4B,KAAK,WAAW,UAAU,MAAM;CAC3E,IAAI,MAAM,QAAQ,GAChB,OAAO;EACL,oBAAoB,IAAI,SAAS;EACjC,yBAAyB;CAC3B;CAEF,IAAI,MAAM,cAAc,GAAG,OAAO,EAAE,oBAAoB,IAAI,eAAe,GAAG;AAEhF;AAEA,MAAM,OAAO;;;;;;;;AASb,SAAgB,WACd,MACA,UACA,gBACQ;CACR,MAAM,EAAE,MAAM,UAAU;CACxB,IAAI,MAAM;CACV,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,QAAQ,OAAO,GAAG,OAAe,OAAe,cAAsB;EAC9E,MAAM,MAAM,kBAAkB,SAAS;EACvC,MAAM,UAAU,kBAAkB,KAAK,cAAc,IAAI,gBAAgB;EACzE,OAAO,GAAG,QAAQ,QAAQ,gBAAgB,KAAK,KAAK,OAAO,CAAC,IAAI;CAClE,CAAC;CAEH,IAAI,UAAU,KAAA,GAAW;EACvB,MAAM,QAAQ,aAAa,gBAAgB,KAAK,EAAE;EAClD,MAAM,YAAY,KAAK,GAAG,IAAI,IAAI,QAAQ,aAAa,GAAG,MAAM,QAAQ,IAAI,GAAG,MAAM;CACvF;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,OAAuB;CAChD,OAAO,MAAM,WAAW,SAAS,GAAG,CAAC,CAAC,WAAW,UAAU,IAAG,CAAC,CAAC,WAAW,UAAU,GAAG;AAC1F;AAEA,SAAS,gBAAgB,OAAuB;CAC9C,OAAO,MAAM,WAAW,KAAK,OAAO,CAAC,CAAC,WAAW,MAAK,QAAQ;AAChE"}
1
+ {"version":3,"file":"email-sender.mjs","names":[],"sources":["../src/email-sender.ts"],"sourcesContent":["import {\n type EmailAddress,\n type EmailRenderOptions,\n type ResolvedEmail,\n isEmailAddress,\n isUnsubscribeLink,\n} from './email';\nimport type { MessageSender, MessageTracking, OutboundMessage, SendResult } from './engine/ports';\nimport type { ScalarIR } from './ir';\n\n/**\n * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,\n * applies its tracking and commits the tracked links — the same on every host — and hands the\n * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare\n * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.\n */\n\n/** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */\nexport interface RenderedEmail extends ResolvedEmail {\n subject: string;\n html: string;\n}\n\n/**\n * Template renderer, injected by the app — this package's core stays free of\n * any react-email dependency (`@shware/workflow/react-email` ships one over a\n * registry). The subject comes back final: it is sent exactly as written, from\n * the template's own sender, and the unsubscribe link is the one the HTML was\n * rendered with. `options` carries the copy's message id when reporting records one, for the\n * preferences link (`resolveEmail`).\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>,\n options?: EmailRenderOptions\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an\n * email that has an unsubscribe link (see `unsubscribeHeaders`).\n */\n headers?: Readonly<Record<string, string>>;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n // A user with no usable address is a fact about that user, not a fault worth retrying: the\n // copy settles undelivered and the journey goes on, the way a push with no device is skipped.\n // Throwing here burned the step's retries and then failed the whole instance.\n if (message.recipient === undefined) return { delivered: false, reason: 'no_recipient' };\n if (!isEmailAddress(message.recipient)) {\n return { delivered: false, reason: 'invalid_recipient' };\n }\n const messageId = message.tracking?.messageId;\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props,\n messageId === undefined ? {} : { messageId }\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n await message.tracking?.commit?.();\n const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);\n await this.options.transport.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n ...(headers === undefined ? {} : { headers }),\n idempotencyKey: message.idempotencyKey,\n });\n }\n}\n\n/**\n * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a\n * mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:\n *\n * - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the\n * header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.\n * - Without one: the header names the email's own unsubscribe link, with no one-click claim —\n * whether that page takes a POST is the app's.\n * - An email without an unsubscribe link (a transactional one) gets neither.\n *\n * Only an https URL is named: mail clients and Cloudflare Email refuse any other.\n */\nexport function unsubscribeHeaders(\n unsubscribeUrl: string | undefined,\n oneClick: string | undefined\n): Record<string, string> | undefined {\n if (unsubscribeUrl === undefined) return undefined;\n const https = (url: string | undefined) => url?.startsWith('https://') === true;\n if (https(oneClick)) {\n return {\n 'List-Unsubscribe': `<${oneClick}>`,\n 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n };\n }\n if (https(unsubscribeUrl)) return { 'List-Unsubscribe': `<${unsubscribeUrl}>` };\n return undefined;\n}\n\nconst LINK = /(<a\\b[^>]*?\\shref\\s*=\\s*)([\"'])(https?:\\/\\/[^\"']+)\\2/gi;\n\n/**\n * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through\n * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —\n * as the unsubscribe link when it is the one the email was rendered with, so what a click on it\n * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the\n * end when there is none.\n */\nexport function trackEmail(\n html: string,\n tracking: MessageTracking,\n unsubscribeUrl?: string\n): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(LINK, (_, start: string, quote: string, attribute: string) => {\n const url = unescapeAttribute(attribute);\n const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? 'unsubscribe' : 'link';\n return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;\n });\n }\n if (pixel !== undefined) {\n const image = `<img src=\"${escapeAttribute(pixel)}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;border:0;width:1px;height:1px\" />`;\n out = /<\\/body>/i.test(out) ? out.replace(/<\\/body>/i, `${image}</body>`) : `${out}${image}`;\n }\n return out;\n}\n\nfunction unescapeAttribute(value: string): string {\n return value.replaceAll('&amp;', '&').replaceAll('&quot;', '\"').replaceAll('&#x27;', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&amp;').replaceAll('\"', '&quot;');\n}\n"],"mappings":";;;;;;;AA2EA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAsD;EAC/D,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAKzE,IAAI,QAAQ,cAAc,KAAA,GAAW,OAAO;GAAE,WAAW;GAAO,QAAQ;EAAe;EACvF,IAAI,CAAC,eAAe,QAAQ,SAAS,GACnC,OAAO;GAAE,WAAW;GAAO,QAAQ;EAAoB;EAEzD,MAAM,YAAY,QAAQ,UAAU;EACpC,MAAM,EAAE,SAAS,MAAM,MAAM,SAAS,mBAAmB,MAAM,KAAK,QAAQ,OAC1E,QAAQ,UACR,QAAQ,OACR,cAAc,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU,CAC7C;EACA,MAAM,UACJ,QAAQ,aAAa,KAAA,IAAY,OAAO,WAAW,MAAM,QAAQ,UAAU,cAAc;EAC3F,MAAM,QAAQ,UAAU,SAAS;EACjC,MAAM,UAAU,mBAAmB,gBAAgB,QAAQ,UAAU,WAAW;EAChF,MAAM,KAAK,QAAQ,UAAU,KAAK;GAChC;GACA,IAAI,QAAQ;GACZ,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C;GACA,MAAM;GACN,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C,gBAAgB,QAAQ;EAC1B,CAAC;CACH;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,gBACA,UACoC;CACpC,IAAI,mBAAmB,KAAA,GAAW,OAAO,KAAA;CACzC,MAAM,SAAS,QAA4B,KAAK,WAAW,UAAU,MAAM;CAC3E,IAAI,MAAM,QAAQ,GAChB,OAAO;EACL,oBAAoB,IAAI,SAAS;EACjC,yBAAyB;CAC3B;CAEF,IAAI,MAAM,cAAc,GAAG,OAAO,EAAE,oBAAoB,IAAI,eAAe,GAAG;AAEhF;AAEA,MAAM,OAAO;;;;;;;;AASb,SAAgB,WACd,MACA,UACA,gBACQ;CACR,MAAM,EAAE,MAAM,UAAU;CACxB,IAAI,MAAM;CACV,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,QAAQ,OAAO,GAAG,OAAe,OAAe,cAAsB;EAC9E,MAAM,MAAM,kBAAkB,SAAS;EACvC,MAAM,UAAU,kBAAkB,KAAK,cAAc,IAAI,gBAAgB;EACzE,OAAO,GAAG,QAAQ,QAAQ,gBAAgB,KAAK,KAAK,OAAO,CAAC,IAAI;CAClE,CAAC;CAEH,IAAI,UAAU,KAAA,GAAW;EACvB,MAAM,QAAQ,aAAa,gBAAgB,KAAK,EAAE;EAClD,MAAM,YAAY,KAAK,GAAG,IAAI,IAAI,QAAQ,aAAa,GAAG,MAAM,QAAQ,IAAI,GAAG,MAAM;CACvF;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,OAAuB;CAChD,OAAO,MAAM,WAAW,SAAS,GAAG,CAAC,CAAC,WAAW,UAAU,IAAG,CAAC,CAAC,WAAW,UAAU,GAAG;AAC1F;AAEA,SAAS,gBAAgB,OAAuB;CAC9C,OAAO,MAAM,WAAW,KAAK,OAAO,CAAC,CAAC,WAAW,MAAK,QAAQ;AAChE"}
package/dist/email.cjs CHANGED
@@ -54,6 +54,10 @@ function preferencesLink(preferencesUrl, messageId) {
54
54
  return url.toString();
55
55
  }
56
56
  const ADDRESS = /^[^\s@<>]+@[^\s@<>]+$/;
57
+ /** Whether `value` is a bare `user@host` address — the shape a profile's email must have to be sent to. */
58
+ function isEmailAddress(value) {
59
+ return ADDRESS.test(value);
60
+ }
57
61
  /** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */
58
62
  function assertEmailAddress(address, where) {
59
63
  const email = typeof address === "object" && address !== null ? address.email : address;
@@ -139,6 +143,7 @@ exports.assertHttpUrl = assertHttpUrl;
139
143
  exports.assertTemplateTopic = assertTemplateTopic;
140
144
  exports.assertUnsubscribeUrl = assertUnsubscribeUrl;
141
145
  exports.formatEmailAddress = formatEmailAddress;
146
+ exports.isEmailAddress = isEmailAddress;
142
147
  exports.isUnsubscribeLink = isUnsubscribeLink;
143
148
  exports.mimeAddress = mimeAddress;
144
149
  exports.preferencesLink = preferencesLink;