@shware/workflow 1.0.0 → 2.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 (106) hide show
  1. package/README.md +42 -11
  2. package/dist/client.d.cts +1 -1
  3. package/dist/client.d.mts +1 -1
  4. package/dist/cloudflare/bindings.cjs.map +1 -1
  5. package/dist/cloudflare/bindings.d.cts +1 -1
  6. package/dist/cloudflare/bindings.d.mts +1 -1
  7. package/dist/cloudflare/bindings.mjs.map +1 -1
  8. package/dist/cloudflare/channels.cjs +1 -4
  9. package/dist/cloudflare/channels.cjs.map +1 -1
  10. package/dist/cloudflare/channels.d.cts +3 -3
  11. package/dist/cloudflare/channels.d.mts +3 -3
  12. package/dist/cloudflare/channels.mjs +1 -4
  13. package/dist/cloudflare/channels.mjs.map +1 -1
  14. package/dist/cloudflare/index.d.cts +4 -4
  15. package/dist/cloudflare/index.d.mts +4 -4
  16. package/dist/cloudflare/router.d.cts +1 -1
  17. package/dist/cloudflare/router.d.mts +1 -1
  18. package/dist/cloudflare/runner.d.cts +2 -2
  19. package/dist/cloudflare/runner.d.mts +2 -2
  20. package/dist/cloudflare/senders.cjs +15 -8
  21. package/dist/cloudflare/senders.cjs.map +1 -1
  22. package/dist/cloudflare/senders.d.cts +15 -11
  23. package/dist/cloudflare/senders.d.cts.map +1 -1
  24. package/dist/cloudflare/senders.d.mts +15 -11
  25. package/dist/cloudflare/senders.d.mts.map +1 -1
  26. package/dist/cloudflare/senders.mjs +15 -8
  27. package/dist/cloudflare/senders.mjs.map +1 -1
  28. package/dist/cloudflare/worker.cjs +7 -4
  29. package/dist/cloudflare/worker.cjs.map +1 -1
  30. package/dist/cloudflare/worker.d.cts +2 -2
  31. package/dist/cloudflare/worker.d.cts.map +1 -1
  32. package/dist/cloudflare/worker.d.mts +2 -2
  33. package/dist/cloudflare/worker.d.mts.map +1 -1
  34. package/dist/cloudflare/worker.mjs +8 -5
  35. package/dist/cloudflare/worker.mjs.map +1 -1
  36. package/dist/config.cjs +8 -9
  37. package/dist/config.cjs.map +1 -1
  38. package/dist/config.d.cts +9 -32
  39. package/dist/config.d.cts.map +1 -1
  40. package/dist/config.d.mts +9 -32
  41. package/dist/config.d.mts.map +1 -1
  42. package/dist/config.mjs +9 -9
  43. package/dist/config.mjs.map +1 -1
  44. package/dist/email-context.cjs +29 -0
  45. package/dist/email-context.cjs.map +1 -0
  46. package/dist/email-context.d.cts +11 -0
  47. package/dist/email-context.d.cts.map +1 -0
  48. package/dist/email-context.d.mts +11 -0
  49. package/dist/email-context.d.mts.map +1 -0
  50. package/dist/email-context.mjs +27 -0
  51. package/dist/email-context.mjs.map +1 -0
  52. package/dist/email.cjs +72 -0
  53. package/dist/email.cjs.map +1 -0
  54. package/dist/email.d.cts +63 -0
  55. package/dist/email.d.cts.map +1 -0
  56. package/dist/email.d.mts +63 -0
  57. package/dist/email.d.mts.map +1 -0
  58. package/dist/email.mjs +65 -0
  59. package/dist/email.mjs.map +1 -0
  60. package/dist/engine/index.d.cts +2 -2
  61. package/dist/engine/index.d.mts +2 -2
  62. package/dist/engine/ports.d.cts +7 -2
  63. package/dist/engine/ports.d.cts.map +1 -1
  64. package/dist/engine/ports.d.mts +7 -2
  65. package/dist/engine/ports.d.mts.map +1 -1
  66. package/dist/index.cjs +8 -1
  67. package/dist/index.d.cts +4 -3
  68. package/dist/index.d.mts +4 -3
  69. package/dist/index.mjs +3 -2
  70. package/dist/react-email.cjs +11 -3
  71. package/dist/react-email.cjs.map +1 -1
  72. package/dist/react-email.d.cts +2 -1
  73. package/dist/react-email.d.cts.map +1 -1
  74. package/dist/react-email.d.mts +2 -1
  75. package/dist/react-email.d.mts.map +1 -1
  76. package/dist/react-email.mjs +11 -3
  77. package/dist/react-email.mjs.map +1 -1
  78. package/dist/reporting/classify.cjs.map +1 -1
  79. package/dist/reporting/classify.d.cts +1 -1
  80. package/dist/reporting/classify.d.mts +1 -1
  81. package/dist/reporting/classify.mjs.map +1 -1
  82. package/dist/reporting/observer.cjs +5 -4
  83. package/dist/reporting/observer.cjs.map +1 -1
  84. package/dist/reporting/observer.d.cts +3 -2
  85. package/dist/reporting/observer.d.cts.map +1 -1
  86. package/dist/reporting/observer.d.mts +3 -2
  87. package/dist/reporting/observer.d.mts.map +1 -1
  88. package/dist/reporting/observer.mjs +5 -4
  89. package/dist/reporting/observer.mjs.map +1 -1
  90. package/dist/reporting/tracking.cjs +3 -12
  91. package/dist/reporting/tracking.cjs.map +1 -1
  92. package/dist/reporting/tracking.d.cts +18 -7
  93. package/dist/reporting/tracking.d.cts.map +1 -1
  94. package/dist/reporting/tracking.d.mts +18 -7
  95. package/dist/reporting/tracking.d.mts.map +1 -1
  96. package/dist/reporting/tracking.mjs +3 -12
  97. package/dist/reporting/tracking.mjs.map +1 -1
  98. package/dist/templates.cjs +6 -1
  99. package/dist/templates.cjs.map +1 -1
  100. package/dist/templates.d.cts +21 -2
  101. package/dist/templates.d.cts.map +1 -1
  102. package/dist/templates.d.mts +21 -2
  103. package/dist/templates.d.mts.map +1 -1
  104. package/dist/templates.mjs +6 -1
  105. package/dist/templates.mjs.map +1 -1
  106. package/package.json +11 -1
@@ -1 +1 @@
1
- {"version":3,"file":"senders.cjs","names":[],"sources":["../../src/cloudflare/senders.ts"],"sourcesContent":["import type { EmailAddress } from '../config';\nimport type { MessageSender, MessageTracking, OutboundMessage } from '../engine';\nimport type { ScalarIR } from '../ir';\nimport type { EmailBindingLike } from './bindings';\n\nexport type { EmailAddress } from '../config';\nexport type { EmailBindingLike } from './bindings';\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.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<{ subject: string; html: string }>;\n\nexport interface CfEmailOptions {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n from: EmailAddress;\n replyTo?: EmailAddress;\n render: EmailRenderer;\n}\n\n/**\n * Send straight through Cloudflare Email Service — a binding call, no outbound\n * HTTP. Throwing hands retries to the CF step, and the message's\n * idempotencyKey travels with the call so the binding can drop the duplicate a\n * retry or replay produces.\n */\nexport class CfEmailSender implements MessageSender {\n constructor(private readonly options: CfEmailOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`CfEmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`CfEmailSender: no recipient for user '${message.userId}'`);\n }\n const { subject, html } = await this.options.render(message.template, message.props);\n const tracked = message.tracking === undefined ? html : trackEmail(html, message.tracking);\n // The tracked links resolve before the email exists anywhere a scanner could follow them\n await message.tracking?.commit?.();\n await this.options.binding.send({\n from: this.options.from,\n to: message.recipient,\n ...(this.options.replyTo === undefined ? {} : { replyTo: this.options.replyTo }),\n subject,\n html: tracked,\n idempotencyKey: message.idempotencyKey,\n });\n }\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 * and the open pixel appended before `</body>`, or at the end when there is none.\n */\nexport function trackEmail(html: string, tracking: MessageTracking): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(\n LINK,\n (_, start: string, quote: string, url: string) =>\n `${start}${quote}${escapeAttribute(link(unescapeAttribute(url)))}${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\n/** Development sender: structured logs, for eyeballing the pipeline. */\nexport class LogMessageSender implements MessageSender {\n async send(message: OutboundMessage): Promise<void> {\n const { tracking: _tracking, ...logged } = message;\n console.log(`[send] ${JSON.stringify(logged)}`);\n }\n}\n\n/**\n * Webhook sender: POST to an external delivery service (a Resend/SES gateway,\n * say). The idempotency key travels with it, so the receiver de-duplicates\n * retries and replays on idempotencyKey.\n */\nexport class WebhookMessageSender implements MessageSender {\n constructor(private readonly url: string) {}\n\n async send(message: OutboundMessage): Promise<void> {\n const response = await fetch(this.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n 'idempotency-key': message.idempotencyKey,\n },\n body: JSON.stringify(message),\n });\n if (!response.ok) {\n throw new Error(`message webhook failed: ${response.status}`);\n }\n }\n}\n\n/**\n * One sender per channel: the runner has a single outlet, this fans it out.\n * A channel without a sender fails the send (a misconfiguration, not a retry).\n */\nexport function routeByChannel(\n senders: Partial<Record<OutboundMessage['channel'], MessageSender>>\n): MessageSender {\n return {\n async send(message) {\n const sender = senders[message.channel];\n if (sender === undefined) {\n throw new Error(`no sender configured for channel '${message.channel}'`);\n }\n return sender.send(message);\n },\n };\n}\n\n/**\n * One push outlet per platform, chosen by the device each copy goes to (the\n * engine sends one message per device, and the host's directory says which\n * platform a token belongs to): `ios` straight to APNs, `android` through FCM,\n * say. A platform with no sender fails the step rather than guessing.\n */\nexport function routeByPlatform(senders: Record<string, MessageSender>): MessageSender {\n return {\n async send(message) {\n const platform = message.device?.platform;\n if (platform === undefined) {\n throw new Error(`push for user '${message.userId}' carries no device`);\n }\n if (!Object.hasOwn(senders, platform)) {\n throw new Error(`no push sender configured for platform '${platform}'`);\n }\n return senders[platform].send(message);\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"senders.cjs","names":["isUnsubscribeLink"],"sources":["../../src/cloudflare/senders.ts"],"sourcesContent":["import { type ResolvedEmail, isUnsubscribeLink } from '../email';\nimport type { MessageSender, MessageTracking, OutboundMessage } from '../engine';\nimport type { ScalarIR } from '../ir';\nimport type { EmailBindingLike } from './bindings';\n\nexport type { EmailAddress } from '../email';\nexport type { EmailBindingLike } from './bindings';\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.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<RenderedEmail>;\n\nexport interface CfEmailOptions {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n render: EmailRenderer;\n}\n\n/**\n * Send straight through Cloudflare Email Service — a binding call, no outbound\n * HTTP. Throwing hands retries to the CF step, and the message's\n * idempotencyKey travels with the call so the binding can drop the duplicate a\n * retry or replay produces.\n */\nexport class CfEmailSender implements MessageSender {\n constructor(private readonly options: CfEmailOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`CfEmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`CfEmailSender: no recipient for user '${message.userId}'`);\n }\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n // The tracked links resolve before the email exists anywhere a scanner could follow them\n await message.tracking?.commit?.();\n await this.options.binding.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n idempotencyKey: message.idempotencyKey,\n });\n }\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\n/** Development sender: structured logs, for eyeballing the pipeline. */\nexport class LogMessageSender implements MessageSender {\n async send(message: OutboundMessage): Promise<void> {\n const { tracking: _tracking, ...logged } = message;\n console.log(`[send] ${JSON.stringify(logged)}`);\n }\n}\n\n/**\n * Webhook sender: POST to an external delivery service (a Resend/SES gateway,\n * say). The idempotency key travels with it, so the receiver de-duplicates\n * retries and replays on idempotencyKey.\n */\nexport class WebhookMessageSender implements MessageSender {\n constructor(private readonly url: string) {}\n\n async send(message: OutboundMessage): Promise<void> {\n const response = await fetch(this.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n 'idempotency-key': message.idempotencyKey,\n },\n body: JSON.stringify(message),\n });\n if (!response.ok) {\n throw new Error(`message webhook failed: ${response.status}`);\n }\n }\n}\n\n/**\n * One sender per channel: the runner has a single outlet, this fans it out.\n * A channel without a sender fails the send (a misconfiguration, not a retry).\n */\nexport function routeByChannel(\n senders: Partial<Record<OutboundMessage['channel'], MessageSender>>\n): MessageSender {\n return {\n async send(message) {\n const sender = senders[message.channel];\n if (sender === undefined) {\n throw new Error(`no sender configured for channel '${message.channel}'`);\n }\n return sender.send(message);\n },\n };\n}\n\n/**\n * One push outlet per platform, chosen by the device each copy goes to (the\n * engine sends one message per device, and the host's directory says which\n * platform a token belongs to): `ios` straight to APNs, `android` through FCM,\n * say. A platform with no sender fails the step rather than guessing.\n */\nexport function routeByPlatform(senders: Record<string, MessageSender>): MessageSender {\n return {\n async send(message) {\n const platform = message.device?.platform;\n if (platform === undefined) {\n throw new Error(`push for user '${message.userId}' carries no device`);\n }\n if (!Object.hasOwn(senders, platform)) {\n throw new Error(`no push sender configured for platform '${platform}'`);\n }\n return senders[platform].send(message);\n },\n };\n}\n"],"mappings":"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"}
@@ -1,23 +1,25 @@
1
1
  import { ScalarIR } from "../ir.cjs";
2
2
  import { MessageSender, MessageTracking, OutboundMessage } from "../engine/ports.cjs";
3
3
  import "../engine/index.cjs";
4
- import { EmailAddress } from "../config.cjs";
4
+ import { EmailAddress, ResolvedEmail } from "../email.cjs";
5
5
  import { EmailBindingLike } from "./bindings.cjs";
6
6
  //#region src/cloudflare/senders.d.ts
7
+ /** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */
8
+ export interface RenderedEmail extends ResolvedEmail {
9
+ subject: string;
10
+ html: string;
11
+ }
7
12
  /**
8
13
  * Template renderer, injected by the app — this package's core stays free of
9
14
  * any react-email dependency (`@shware/workflow/react-email` ships one over a
10
- * registry). The subject comes back final: it is sent exactly as written.
15
+ * registry). The subject comes back final: it is sent exactly as written, from
16
+ * the template's own sender, and the unsubscribe link is the one the HTML was
17
+ * rendered with.
11
18
  */
12
- export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined>) => Promise<{
13
- subject: string;
14
- html: string;
15
- }>;
19
+ export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined>) => Promise<RenderedEmail>;
16
20
  export interface CfEmailOptions {
17
21
  /** The Worker's `send_email` binding. */
18
22
  binding: EmailBindingLike;
19
- from: EmailAddress;
20
- replyTo?: EmailAddress;
21
23
  render: EmailRenderer;
22
24
  }
23
25
  /**
@@ -33,10 +35,12 @@ export declare class CfEmailSender implements MessageSender {
33
35
  }
34
36
  /**
35
37
  * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
36
- * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back),
37
- * and the open pixel appended before `</body>`, or at the end when there is none.
38
+ * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
39
+ * as the unsubscribe link when it is the one the email was rendered with, so what a click on it
40
+ * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the
41
+ * end when there is none.
38
42
  */
39
- export declare function trackEmail(html: string, tracking: MessageTracking): string;
43
+ export declare function trackEmail(html: string, tracking: MessageTracking, unsubscribeUrl?: string): string;
40
44
  /** Development sender: structured logs, for eyeballing the pipeline. */
41
45
  export declare class LogMessageSender implements MessageSender {
42
46
  send(message: OutboundMessage): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"senders.d.cts","names":[],"sources":["../../src/cloudflare/senders.ts"],"mappings":";;;;;;;;;;;YAaY,iBACV,kBACA,OAAO,eAAe,0BACnB;EAAU;EAAiB;;iBAEf;;EAEf,SAAS;EACT,MAAM;EACN,UAAU;EACV,QAAQ;;;;;;;;qBASG,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;wBA6BxB,WAAW,cAAc,UAAU;;qBA0BtC,4BAA4B;EACjC,KAAK,SAAS,kBAAkB;;;;;;;qBAW3B,gCAAgC;mBACd;EAA7B,YAA6B;EAEvB,KAAK,SAAS,kBAAkB;;;;;;wBAmBxB,eACd,SAAS,QAAQ,OAAO,4BAA4B,kBACnD;;;;;;;wBAkBa,gBAAgB,SAAS,eAAe,iBAAiB"}
1
+ {"version":3,"file":"senders.d.cts","names":[],"sources":["../../src/cloudflare/senders.ts"],"mappings":";;;;;;;iBASiB,sBAAsB;EACrC;EACA;;;;;;;;;YAUU,iBACV,kBACA,OAAO,eAAe,0BACnB,QAAQ;iBAEI;;EAEf,SAAS;EACT,QAAQ;;;;;;;;qBASG,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;wBAmCxB,WACd,cACA,UAAU,iBACV;;qBA2BW,4BAA4B;EACjC,KAAK,SAAS,kBAAkB;;;;;;;qBAW3B,gCAAgC;mBACd;EAA7B,YAA6B;EAEvB,KAAK,SAAS,kBAAkB;;;;;;wBAmBxB,eACd,SAAS,QAAQ,OAAO,4BAA4B,kBACnD;;;;;;;wBAkBa,gBAAgB,SAAS,eAAe,iBAAiB"}
@@ -1,23 +1,25 @@
1
1
  import { ScalarIR } from "../ir.mjs";
2
2
  import { MessageSender, MessageTracking, OutboundMessage } from "../engine/ports.mjs";
3
3
  import "../engine/index.mjs";
4
- import { EmailAddress } from "../config.mjs";
4
+ import { EmailAddress, ResolvedEmail } from "../email.mjs";
5
5
  import { EmailBindingLike } from "./bindings.mjs";
6
6
  //#region src/cloudflare/senders.d.ts
7
+ /** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */
8
+ export interface RenderedEmail extends ResolvedEmail {
9
+ subject: string;
10
+ html: string;
11
+ }
7
12
  /**
8
13
  * Template renderer, injected by the app — this package's core stays free of
9
14
  * any react-email dependency (`@shware/workflow/react-email` ships one over a
10
- * registry). The subject comes back final: it is sent exactly as written.
15
+ * registry). The subject comes back final: it is sent exactly as written, from
16
+ * the template's own sender, and the unsubscribe link is the one the HTML was
17
+ * rendered with.
11
18
  */
12
- export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined>) => Promise<{
13
- subject: string;
14
- html: string;
15
- }>;
19
+ export type EmailRenderer = (template: string, props: Record<string, ScalarIR | undefined>) => Promise<RenderedEmail>;
16
20
  export interface CfEmailOptions {
17
21
  /** The Worker's `send_email` binding. */
18
22
  binding: EmailBindingLike;
19
- from: EmailAddress;
20
- replyTo?: EmailAddress;
21
23
  render: EmailRenderer;
22
24
  }
23
25
  /**
@@ -33,10 +35,12 @@ export declare class CfEmailSender implements MessageSender {
33
35
  }
34
36
  /**
35
37
  * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
36
- * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back),
37
- * and the open pixel appended before `</body>`, or at the end when there is none.
38
+ * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
39
+ * as the unsubscribe link when it is the one the email was rendered with, so what a click on it
40
+ * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the
41
+ * end when there is none.
38
42
  */
39
- export declare function trackEmail(html: string, tracking: MessageTracking): string;
43
+ export declare function trackEmail(html: string, tracking: MessageTracking, unsubscribeUrl?: string): string;
40
44
  /** Development sender: structured logs, for eyeballing the pipeline. */
41
45
  export declare class LogMessageSender implements MessageSender {
42
46
  send(message: OutboundMessage): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"senders.d.mts","names":[],"sources":["../../src/cloudflare/senders.ts"],"mappings":";;;;;;;;;;;YAaY,iBACV,kBACA,OAAO,eAAe,0BACnB;EAAU;EAAiB;;iBAEf;;EAEf,SAAS;EACT,MAAM;EACN,UAAU;EACV,QAAQ;;;;;;;;qBASG,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;wBA6BxB,WAAW,cAAc,UAAU;;qBA0BtC,4BAA4B;EACjC,KAAK,SAAS,kBAAkB;;;;;;;qBAW3B,gCAAgC;mBACd;EAA7B,YAA6B;EAEvB,KAAK,SAAS,kBAAkB;;;;;;wBAmBxB,eACd,SAAS,QAAQ,OAAO,4BAA4B,kBACnD;;;;;;;wBAkBa,gBAAgB,SAAS,eAAe,iBAAiB"}
1
+ {"version":3,"file":"senders.d.mts","names":[],"sources":["../../src/cloudflare/senders.ts"],"mappings":";;;;;;;iBASiB,sBAAsB;EACrC;EACA;;;;;;;;;YAUU,iBACV,kBACA,OAAO,eAAe,0BACnB,QAAQ;iBAEI;;EAEf,SAAS;EACT,QAAQ;;;;;;;;qBASG,yBAAyB;mBACP;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;wBAmCxB,WACd,cACA,UAAU,iBACV;;qBA2BW,4BAA4B;EACjC,KAAK,SAAS,kBAAkB;;;;;;;qBAW3B,gCAAgC;mBACd;EAA7B,YAA6B;EAEvB,KAAK,SAAS,kBAAkB;;;;;;wBAmBxB,eACd,SAAS,QAAQ,OAAO,4BAA4B,kBACnD;;;;;;;wBAkBa,gBAAgB,SAAS,eAAe,iBAAiB"}
@@ -1,3 +1,4 @@
1
+ import { isUnsubscribeLink } from "../email.mjs";
1
2
  //#region src/cloudflare/senders.ts
2
3
  /**
3
4
  * Send straight through Cloudflare Email Service — a binding call, no outbound
@@ -13,13 +14,13 @@ var CfEmailSender = class {
13
14
  async send(message) {
14
15
  if (message.channel !== "email") throw new Error(`CfEmailSender: unsupported channel '${message.channel}'`);
15
16
  if (message.recipient === void 0) throw new Error(`CfEmailSender: no recipient for user '${message.userId}'`);
16
- const { subject, html } = await this.options.render(message.template, message.props);
17
- const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking);
17
+ const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props);
18
+ const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
18
19
  await message.tracking?.commit?.();
19
20
  await this.options.binding.send({
20
- from: this.options.from,
21
+ from,
21
22
  to: message.recipient,
22
- ...this.options.replyTo === void 0 ? {} : { replyTo: this.options.replyTo },
23
+ ...replyTo === void 0 ? {} : { replyTo },
23
24
  subject,
24
25
  html: tracked,
25
26
  idempotencyKey: message.idempotencyKey
@@ -29,13 +30,19 @@ var CfEmailSender = class {
29
30
  const LINK = /(<a\b[^>]*?\shref\s*=\s*)(["'])(https?:\/\/[^"']+)\2/gi;
30
31
  /**
31
32
  * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
32
- * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back),
33
- * and the open pixel appended before `</body>`, or at the end when there is none.
33
+ * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
34
+ * as the unsubscribe link when it is the one the email was rendered with, so what a click on it
35
+ * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the
36
+ * end when there is none.
34
37
  */
35
- function trackEmail(html, tracking) {
38
+ function trackEmail(html, tracking, unsubscribeUrl) {
36
39
  const { link, pixel } = tracking;
37
40
  let out = html;
38
- if (link !== void 0) out = out.replace(LINK, (_, start, quote, url) => `${start}${quote}${escapeAttribute(link(unescapeAttribute(url)))}${quote}`);
41
+ if (link !== void 0) out = out.replace(LINK, (_, start, quote, attribute) => {
42
+ const url = unescapeAttribute(attribute);
43
+ const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? "unsubscribe" : "link";
44
+ return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;
45
+ });
39
46
  if (pixel !== void 0) {
40
47
  const image = `<img src="${escapeAttribute(pixel)}" width="1" height="1" alt="" style="display:block;border:0;width:1px;height:1px" />`;
41
48
  out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${image}</body>`) : `${out}${image}`;
@@ -1 +1 @@
1
- {"version":3,"file":"senders.mjs","names":[],"sources":["../../src/cloudflare/senders.ts"],"sourcesContent":["import type { EmailAddress } from '../config';\nimport type { MessageSender, MessageTracking, OutboundMessage } from '../engine';\nimport type { ScalarIR } from '../ir';\nimport type { EmailBindingLike } from './bindings';\n\nexport type { EmailAddress } from '../config';\nexport type { EmailBindingLike } from './bindings';\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.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<{ subject: string; html: string }>;\n\nexport interface CfEmailOptions {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n from: EmailAddress;\n replyTo?: EmailAddress;\n render: EmailRenderer;\n}\n\n/**\n * Send straight through Cloudflare Email Service — a binding call, no outbound\n * HTTP. Throwing hands retries to the CF step, and the message's\n * idempotencyKey travels with the call so the binding can drop the duplicate a\n * retry or replay produces.\n */\nexport class CfEmailSender implements MessageSender {\n constructor(private readonly options: CfEmailOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`CfEmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`CfEmailSender: no recipient for user '${message.userId}'`);\n }\n const { subject, html } = await this.options.render(message.template, message.props);\n const tracked = message.tracking === undefined ? html : trackEmail(html, message.tracking);\n // The tracked links resolve before the email exists anywhere a scanner could follow them\n await message.tracking?.commit?.();\n await this.options.binding.send({\n from: this.options.from,\n to: message.recipient,\n ...(this.options.replyTo === undefined ? {} : { replyTo: this.options.replyTo }),\n subject,\n html: tracked,\n idempotencyKey: message.idempotencyKey,\n });\n }\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 * and the open pixel appended before `</body>`, or at the end when there is none.\n */\nexport function trackEmail(html: string, tracking: MessageTracking): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(\n LINK,\n (_, start: string, quote: string, url: string) =>\n `${start}${quote}${escapeAttribute(link(unescapeAttribute(url)))}${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\n/** Development sender: structured logs, for eyeballing the pipeline. */\nexport class LogMessageSender implements MessageSender {\n async send(message: OutboundMessage): Promise<void> {\n const { tracking: _tracking, ...logged } = message;\n console.log(`[send] ${JSON.stringify(logged)}`);\n }\n}\n\n/**\n * Webhook sender: POST to an external delivery service (a Resend/SES gateway,\n * say). The idempotency key travels with it, so the receiver de-duplicates\n * retries and replays on idempotencyKey.\n */\nexport class WebhookMessageSender implements MessageSender {\n constructor(private readonly url: string) {}\n\n async send(message: OutboundMessage): Promise<void> {\n const response = await fetch(this.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n 'idempotency-key': message.idempotencyKey,\n },\n body: JSON.stringify(message),\n });\n if (!response.ok) {\n throw new Error(`message webhook failed: ${response.status}`);\n }\n }\n}\n\n/**\n * One sender per channel: the runner has a single outlet, this fans it out.\n * A channel without a sender fails the send (a misconfiguration, not a retry).\n */\nexport function routeByChannel(\n senders: Partial<Record<OutboundMessage['channel'], MessageSender>>\n): MessageSender {\n return {\n async send(message) {\n const sender = senders[message.channel];\n if (sender === undefined) {\n throw new Error(`no sender configured for channel '${message.channel}'`);\n }\n return sender.send(message);\n },\n };\n}\n\n/**\n * One push outlet per platform, chosen by the device each copy goes to (the\n * engine sends one message per device, and the host's directory says which\n * platform a token belongs to): `ios` straight to APNs, `android` through FCM,\n * say. A platform with no sender fails the step rather than guessing.\n */\nexport function routeByPlatform(senders: Record<string, MessageSender>): MessageSender {\n return {\n async send(message) {\n const platform = message.device?.platform;\n if (platform === undefined) {\n throw new Error(`push for user '${message.userId}' carries no device`);\n }\n if (!Object.hasOwn(senders, platform)) {\n throw new Error(`no push sender configured for platform '${platform}'`);\n }\n return senders[platform].send(message);\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"senders.mjs","names":[],"sources":["../../src/cloudflare/senders.ts"],"sourcesContent":["import { type ResolvedEmail, isUnsubscribeLink } from '../email';\nimport type { MessageSender, MessageTracking, OutboundMessage } from '../engine';\nimport type { ScalarIR } from '../ir';\nimport type { EmailBindingLike } from './bindings';\n\nexport type { EmailAddress } from '../email';\nexport type { EmailBindingLike } from './bindings';\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.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<RenderedEmail>;\n\nexport interface CfEmailOptions {\n /** The Worker's `send_email` binding. */\n binding: EmailBindingLike;\n render: EmailRenderer;\n}\n\n/**\n * Send straight through Cloudflare Email Service — a binding call, no outbound\n * HTTP. Throwing hands retries to the CF step, and the message's\n * idempotencyKey travels with the call so the binding can drop the duplicate a\n * retry or replay produces.\n */\nexport class CfEmailSender implements MessageSender {\n constructor(private readonly options: CfEmailOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`CfEmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`CfEmailSender: no recipient for user '${message.userId}'`);\n }\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n // The tracked links resolve before the email exists anywhere a scanner could follow them\n await message.tracking?.commit?.();\n await this.options.binding.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n idempotencyKey: message.idempotencyKey,\n });\n }\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\n/** Development sender: structured logs, for eyeballing the pipeline. */\nexport class LogMessageSender implements MessageSender {\n async send(message: OutboundMessage): Promise<void> {\n const { tracking: _tracking, ...logged } = message;\n console.log(`[send] ${JSON.stringify(logged)}`);\n }\n}\n\n/**\n * Webhook sender: POST to an external delivery service (a Resend/SES gateway,\n * say). The idempotency key travels with it, so the receiver de-duplicates\n * retries and replays on idempotencyKey.\n */\nexport class WebhookMessageSender implements MessageSender {\n constructor(private readonly url: string) {}\n\n async send(message: OutboundMessage): Promise<void> {\n const response = await fetch(this.url, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n 'idempotency-key': message.idempotencyKey,\n },\n body: JSON.stringify(message),\n });\n if (!response.ok) {\n throw new Error(`message webhook failed: ${response.status}`);\n }\n }\n}\n\n/**\n * One sender per channel: the runner has a single outlet, this fans it out.\n * A channel without a sender fails the send (a misconfiguration, not a retry).\n */\nexport function routeByChannel(\n senders: Partial<Record<OutboundMessage['channel'], MessageSender>>\n): MessageSender {\n return {\n async send(message) {\n const sender = senders[message.channel];\n if (sender === undefined) {\n throw new Error(`no sender configured for channel '${message.channel}'`);\n }\n return sender.send(message);\n },\n };\n}\n\n/**\n * One push outlet per platform, chosen by the device each copy goes to (the\n * engine sends one message per device, and the host's directory says which\n * platform a token belongs to): `ios` straight to APNs, `android` through FCM,\n * say. A platform with no sender fails the step rather than guessing.\n */\nexport function routeByPlatform(senders: Record<string, MessageSender>): MessageSender {\n return {\n async send(message) {\n const platform = message.device?.platform;\n if (platform === undefined) {\n throw new Error(`push for user '${message.userId}' carries no device`);\n }\n if (!Object.hasOwn(senders, platform)) {\n throw new Error(`no push sender configured for platform '${platform}'`);\n }\n return senders[platform].send(message);\n },\n };\n}\n"],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_runtime = require("../_virtual/_rolldown/runtime.cjs");
3
+ const require_email = require("../email.cjs");
3
4
  const require_templates = require("../templates.cjs");
4
5
  const require_config = require("../config.cjs");
5
6
  const require_contract = require("../contract.cjs");
@@ -39,6 +40,8 @@ var EnvValidationError = class extends Error {
39
40
  };
40
41
  function journeyWorker(options) {
41
42
  const { config } = options;
43
+ const emailIds = require_templates.indexTemplates("email", config.templates?.email);
44
+ require_email.assertEmailConfig(config, emailIds.values());
42
45
  const checked = /* @__PURE__ */ new WeakMap();
43
46
  const checkEnv = (env) => {
44
47
  if (options.env === void 0) return env;
@@ -109,7 +112,7 @@ function journeyWorker(options) {
109
112
  headers: { "content-type": "application/json" }
110
113
  });
111
114
  const authorized = (request, env) => env.API_TOKEN === void 0 || request.headers.get("authorization") === `Bearer ${env.API_TOKEN}`;
112
- const preview = emails === void 0 ? void 0 : require_react_email.registryRenderer(emails);
115
+ const preview = emails === void 0 ? void 0 : require_react_email.registryRenderer(emails, config.email);
113
116
  let applying;
114
117
  let retryAt = 0;
115
118
  const applyOwnBundle = (env) => {
@@ -158,9 +161,9 @@ function journeyWorker(options) {
158
161
  if (url.pathname.startsWith("/t/") && store.reporting !== void 0) {
159
162
  const openedEvent = config.webPush?.openedEvent;
160
163
  const tracked = await require_reporting_tracking.handleTracking(request, store.reporting, {
161
- unsubscribe: config.emails?.unsubscribe,
164
+ legacyUnsubscribeUrl: config.email?.unsubscribeUrl,
162
165
  product: config.title,
163
- quickMs: config.emails?.tracking?.quickClickMs,
166
+ quickMs: config.email?.tracking?.quickClickMs,
164
167
  pushOpened: openedEvent === void 0 ? void 0 : async ({ messageId, userId, at }) => {
165
168
  const payload = { message_id: messageId };
166
169
  const contract = config.contract;
@@ -204,7 +207,7 @@ function journeyWorker(options) {
204
207
  if (!authorized(request, env)) return json({ error: "unauthorized" }, 401);
205
208
  if (request.method !== "GET") return json({ error: "method not allowed" }, 405);
206
209
  const key = url.pathname.slice(9);
207
- if (!Object.hasOwn(emails, key)) return json({ error: `unknown email template: ${key}` }, 404);
210
+ if (!emailIds.has(key)) return json({ error: `unknown email template: ${key}` }, 404);
208
211
  let props = {};
209
212
  try {
210
213
  props = JSON.parse(url.searchParams.get("props") ?? "{}");
@@ -1 +1 @@
1
- {"version":3,"file":"worker.cjs","names":["z","compileConfig","d1Store","runtimeFromConfig","scaleSegmentWindows","JourneyRunner","registryValidator","channelsFromConfig","reportingObserver","registryRenderer","deployBundle","handleTracking","checkEvent","ContractValidationError","ingestEvent","sweepSegments","SchemaMismatchError","TemplateValidationError","handleRequest"],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { type JourneyConfig, compileConfig } from '../config';\nimport { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport type { RegisteredAction } from '../engine/actions';\nimport type { JourneyObserver, MessageSender, MessageValidator } from '../engine/ports';\nimport { scaleSegmentWindows } from '../engine/time-scale';\nimport type { ScalarIR } from '../ir';\nimport { registryRenderer } from '../react-email';\nimport { reportingObserver } from '../reporting/observer';\nimport { handleTracking } from '../reporting/tracking';\nimport type { JourneyRuntimeOptions } from '../runtime';\nimport {\n type BundleVersion,\n type JourneyStore,\n SchemaMismatchError,\n type StoreLease,\n} from '../store/index';\nimport { DEFAULT_SCHEMA } from '../store/tables';\nimport { TemplateValidationError, registryValidator } from '../templates';\nimport type { JourneyEnv, JourneyParams } from './bindings';\nimport { channelsFromConfig, runtimeFromConfig } from './channels';\nimport { d1Store } from './d1-env';\nimport {\n type SweepResult,\n deployBundle,\n handleRequest,\n ingestEvent,\n sweepSegments,\n} from './router';\nimport { JourneyRunner } from './runner';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\nexport interface JourneyWorkerOptions<Env extends JourneyEnv> {\n /**\n * The project's `workflow.config.ts` (`defineConfig`): what the Worker\n * deploys (`GET /bundle`, `POST /bundle/deploy` serve `compileConfig`), the\n * template registries the senders, the validator and `/preview` render from,\n * the contract every `/events` and `/identify` is checked against, the email\n * envelope, actions, and the runtime / APNs / FCM settings. Secrets come\n * from the env under the names `JourneyEnv` documents.\n */\n config: JourneyConfig;\n /**\n * The data plane, opened per request and per workflow run. Return a\n * `StoreLease` when the client must be released afterwards (a Postgres\n * connection); defaults to D1 + KV from the env's bindings. `database` is\n * the config's (`schema` defaulting to `workflow`), for a Postgres store:\n *\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database)\n */\n store?: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /**\n * Reporting hooks: node arrivals, every copy sent (with the tracking it should carry — click\n * links and an open pixel for email, payload data for a push), and how each instance ended.\n * By default a store that keeps reporting (Postgres) records them itself, and the Worker\n * serves the email tracking routes (`/t/...`, on `WORKFLOW_ORIGIN`); override to report\n * elsewhere.\n */\n observer?: (env: Env, store: JourneyStore) => JourneyObserver;\n /** Message outlet override (tests, a custom gateway); by default the channels the config describes. */\n messages?: (env: Env, store: JourneyStore) => MessageSender;\n /** Per-instance runtime options override (a tester in fast-forward); by default `config.runtime` for `ENVIRONMENT`. */\n runtime?: (env: Env, params: JourneyParams) => JourneyRuntimeOptions;\n /**\n * The host's environment, as a zod schema over its vars and secrets (bindings pass through\n * untouched): checked the first time the Worker runs, so a missing or malformed variable fails\n * there — every problem listed — not at the send that needs it. Webhook templates read their\n * URL from the parsed values: `webhook: (env: Env) => env.WEBHOOK_DISCORD_ALERTS`.\n */\n env?: core.$ZodType<object>;\n /** 'enforce' (default) answers an event or patch the contract refuses with a 400; 'report' only logs it. */\n contractMode?: 'enforce' | 'report';\n}\n\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 */\n/** The Worker's environment failed its schema (`journeyWorker({ env })`). */\nexport class EnvValidationError extends Error {\n override readonly name = 'EnvValidationError';\n constructor(readonly issues: readonly string[]) {\n super(`the Worker's environment is invalid:\\n ${issues.join('\\n ')}`);\n }\n}\n\nexport function journeyWorker<Env extends JourneyEnv>(\n options: JourneyWorkerOptions<Env>\n): JourneyWorker<Env> {\n const { config } = options;\n // Parsed once per env object: the runtime hands the same one to every request of an isolate\n const checked = new WeakMap<object, Env>();\n const checkEnv = (env: Env): Env => {\n if (options.env === undefined) return env;\n const cached = checked.get(env);\n if (cached !== undefined) return cached;\n const result = z.safeParse(options.env, env);\n if (!result.success) {\n // Names and reasons only: the values are secrets\n throw new EnvValidationError(\n result.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)\n );\n }\n const merged: Env = { ...env, ...result.data };\n checked.set(env, merged);\n return merged;\n };\n const bundle = () => compileConfig(config);\n const emails = config.templates?.email;\n const database = { schema: config.database?.schema ?? DEFAULT_SCHEMA };\n // In fast-forward, segment windows scale with the journeys reading them (time-scale.ts)\n const lease = (env: Env): Required<StoreLease> => {\n const opened = options.store === undefined ? d1Store(env) : options.store(env, database);\n const noop = async () => {};\n const factor = runtimeFromConfig(config, env).timeScale;\n return 'store' in opened\n ? { store: scaleSegmentWindows(opened.store, factor), close: opened.close ?? noop }\n : { store: scaleSegmentWindows(opened, factor), close: noop };\n };\n\n class Runner extends JourneyRunner {\n private readonly leases = new WeakMap<JourneyStore, () => Promise<void>>();\n private current: JourneyStore | undefined;\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 } = lease(checkEnv(this.env as Env));\n this.leases.set(store, close);\n this.current = store;\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 contract(): Contract | undefined {\n return config.contract;\n }\n\n protected override createMessageValidator(): MessageValidator {\n return registryValidator(config.templates ?? {});\n }\n\n protected override createMessageSender(): MessageSender {\n if (this.current === undefined) return super.createMessageSender();\n if (options.messages !== undefined) {\n return options.messages(checkEnv(this.env as Env), this.current);\n }\n return channelsFromConfig(config, checkEnv(this.env as Env), { store: this.current });\n }\n\n protected override createObserver(store: JourneyStore): JourneyObserver | undefined {\n if (options.observer !== undefined) return options.observer(checkEnv(this.env as Env), store);\n return store.reporting === undefined\n ? undefined\n : reportingObserver(store.reporting, checkEnv(this.env as Env).WORKFLOW_ORIGIN);\n }\n\n protected override runtime(params: JourneyParams): JourneyRuntimeOptions {\n const env = checkEnv(this.env as Env);\n if (options.runtime !== undefined) return options.runtime(env, params);\n return runtimeFromConfig(config, env);\n }\n\n protected override actions(): readonly RegisteredAction[] {\n // An action ref is a registered action structurally: the same object carries name, code and hash\n return config.actions ?? [];\n }\n }\n\n const json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n });\n\n const authorized = (request: Request, env: Env): boolean =>\n env.API_TOKEN === undefined ||\n request.headers.get('authorization') === `Bearer ${env.API_TOKEN}`;\n\n const preview = emails === undefined ? undefined : registryRenderer(emails);\n\n // The bundle compiled into this version goes live on its own, once per isolate: the first\n // request or cron run reads which version's bundle is live (one row) and applies this one only\n // over an older version, so one still serving during a rollout can never put its routes back.\n // A failure (say the migrations have not run) is logged and retried a minute later. Only the\n // request that starts the check gets its promise back: a request context cannot wait on\n // another's I/O.\n let applying: Promise<void> | undefined;\n let retryAt = 0;\n const applyOwnBundle = (env: Env): Promise<void> | undefined => {\n const metadata = env.CF_VERSION_METADATA;\n if (metadata === undefined || metadata.id === '') return undefined;\n const version: BundleVersion = { id: metadata.id, createdAt: Date.parse(metadata.timestamp) };\n if (Number.isNaN(version.createdAt)) return undefined;\n if (applying !== undefined || Date.now() < retryAt) return undefined;\n applying = (async () => {\n const { store, close } = lease(env);\n try {\n if (store.liveBundleVersion === undefined) return; // D1: POST /bundle/deploy\n const live = await store.liveBundleVersion();\n if (live !== undefined && (live.id === version.id || live.createdAt >= version.createdAt))\n return;\n const result = await deployBundle(env, bundle(), store, version);\n if (result.applied) console.log(`[workflow] version ${version.id} is live`);\n } finally {\n await close();\n }\n })().catch((error: unknown) => {\n applying = undefined;\n retryAt = Date.now() + 60_000;\n console.error(`[workflow] could not make version ${version.id} live`, error);\n });\n return applying;\n };\n\n const fetch = async (request: Request, rawEnv: Env, ctx?: ExecutionContextLike) => {\n // Everything below reads the validated environment (the schema's defaults applied)\n let env: Env;\n try {\n env = checkEnv(rawEnv);\n } catch (error) {\n if (!(error instanceof EnvValidationError)) throw error;\n console.error(`[workflow] ${error.message}`);\n return json({ error: error.message }, 500);\n }\n const pending = applyOwnBundle(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 url = new URL(request.url);\n const { store, close } = lease(env);\n try {\n // Opened by recipients, so no bearer: the routes only record, and redirect to recorded URLs\n if (url.pathname.startsWith('/t/') && store.reporting !== undefined) {\n const openedEvent = config.webPush?.openedEvent;\n const tracked = await handleTracking(request, store.reporting, {\n unsubscribe: config.emails?.unsubscribe,\n product: config.title,\n quickMs: config.emails?.tracking?.quickClickMs,\n pushOpened:\n openedEvent === undefined\n ? undefined\n : async ({ messageId, userId, at }) => {\n const payload: Record<string, unknown> = { message_id: messageId };\n const contract = config.contract;\n const verdict =\n contract === undefined ? undefined : checkEvent(contract, openedEvent, payload);\n if (verdict !== undefined && !verdict.ok) {\n throw new ContractValidationError(openedEvent, verdict.issues);\n }\n // Keyed by the copy: a second tap of the same notification is the same event\n await ingestEvent(\n env,\n {\n id: `${openedEvent}:${messageId}`,\n userId,\n event: openedEvent,\n payload: verdict?.data ?? payload,\n ts: at,\n },\n store\n );\n },\n });\n if (tracked !== undefined) return tracked;\n }\n if (url.pathname === '/segments/sweep') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'POST') return json({ error: 'method not allowed' }, 405);\n return json(await sweepSegments(env, store));\n }\n if (url.pathname === '/bundle') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'GET') return json({ error: 'method not allowed' }, 405);\n return json(bundle());\n }\n if (url.pathname === '/bundle/deploy') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'POST') return json({ error: 'method not allowed' }, 405);\n try {\n return json(await deployBundle(env, bundle(), store));\n } catch (error) {\n if (!(error instanceof SchemaMismatchError)) throw error;\n return json({ error: error.message, problems: error.problems }, 503);\n }\n }\n if (emails !== undefined && preview !== undefined && url.pathname.startsWith('/preview/')) {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'GET') return json({ error: 'method not allowed' }, 405);\n const key = url.pathname.slice('/preview/'.length);\n if (!Object.hasOwn(emails, key))\n return json({ error: `unknown email template: ${key}` }, 404);\n // `?props=<JSON object>`: JSON keeps numbers and booleans typed, which query strings cannot\n let props: unknown = {};\n try {\n props = JSON.parse(url.searchParams.get('props') ?? '{}');\n } catch {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n if (typeof props !== 'object' || props === null || Array.isArray(props)) {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n try {\n const { subject, html } = await preview(key, props as Record<string, ScalarIR>);\n return new Response(html, {\n headers: { 'content-type': 'text/html; charset=utf-8', 'x-subject': subject },\n });\n } catch (error) {\n // The query string is the props: a refusal is the caller's to fix, with the field paths\n if (error instanceof TemplateValidationError) {\n return json({ error: error.message, issues: error.issues }, 400);\n }\n throw error;\n }\n }\n return await handleRequest(request, env, store, {\n contract: config.contract,\n mode: options.contractMode,\n });\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 = async (rawEnv: Env) => {\n const env = checkEnv(rawEnv);\n const { store, close } = lease(env);\n try {\n return await sweepSegments(env, store);\n } finally {\n await close();\n }\n };\n\n const scheduled = async (_controller: unknown, env: Env, ctx: ExecutionContextLike) => {\n // A version with no traffic yet goes live here; the sweep then reads its segments\n ctx.waitUntil(\n (async () => {\n await applyOwnBundle(checkEnv(env));\n await sweep(env);\n })()\n );\n };\n\n return { Runner, fetch, sweep, scheduled };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4GA,IAAa,qBAAb,cAAwC,MAAM;CAEvB;CADrB,OAAyB;CACzB,YAAY,QAAoC;EAC9C,MAAM,2CAA2C,OAAO,KAAK,MAAM,GAAG;EADnD,KAAA,SAAA;CAErB;AACF;AAEA,SAAgB,cACd,SACoB;CACpB,MAAM,EAAE,WAAW;CAEnB,MAAM,0BAAU,IAAI,QAAqB;CACzC,MAAM,YAAY,QAAkB;EAClC,IAAI,QAAQ,QAAQ,KAAA,GAAW,OAAO;EACtC,MAAM,SAAS,QAAQ,IAAI,GAAG;EAC9B,IAAI,WAAW,KAAA,GAAW,OAAO;EACjC,MAAM,SAASA,SAAE,UAAU,QAAQ,KAAK,GAAG;EAC3C,IAAI,CAAC,OAAO,SAEV,MAAM,IAAI,mBACR,OAAO,MAAM,OAAO,KAAK,UAAU,GAAG,MAAM,KAAK,KAAK,GAAG,KAAK,SAAS,IAAI,MAAM,SAAS,CAC5F;EAEF,MAAM,SAAc;GAAE,GAAG;GAAK,GAAG,OAAO;EAAK;EAC7C,QAAQ,IAAI,KAAK,MAAM;EACvB,OAAO;CACT;CACA,MAAM,eAAeC,eAAAA,cAAc,MAAM;CACzC,MAAM,SAAS,OAAO,WAAW;CACjC,MAAM,WAAW,EAAE,QAAQ,OAAO,UAAU,UAAA,WAAyB;CAErE,MAAM,SAAS,QAAmC;EAChD,MAAM,SAAS,QAAQ,UAAU,KAAA,IAAYC,0BAAAA,QAAQ,GAAG,IAAI,QAAQ,MAAM,KAAK,QAAQ;EACvF,MAAM,OAAO,YAAY,CAAC;EAC1B,MAAM,SAASC,4BAAAA,kBAAkB,QAAQ,GAAG,CAAC,CAAC;EAC9C,OAAO,WAAW,SACd;GAAE,OAAOC,0BAAAA,oBAAoB,OAAO,OAAO,MAAM;GAAG,OAAO,OAAO,SAAS;EAAK,IAChF;GAAE,OAAOA,0BAAAA,oBAAoB,QAAQ,MAAM;GAAG,OAAO;EAAK;CAChE;CAEA,MAAM,eAAeC,0BAAAA,cAAc;EACjC,yBAA0B,IAAI,QAA2C;EACzE;EAEA,cAA+C;GAE7C,MAAM,EAAE,OAAO,UAAU,MAAM,SAAS,KAAK,GAAU,CAAC;GACxD,KAAK,OAAO,IAAI,OAAO,KAAK;GAC5B,KAAK,UAAU;GACf,OAAO;EACT;EAEA,MAAyB,WAAW,OAAoC;GACtE,MAAM,KAAK,OAAO,IAAI,KAAK,CAAC,GAAG;EACjC;EAEA,WAAoD;GAClD,OAAO,OAAO;EAChB;EAEA,yBAA8D;GAC5D,OAAOC,kBAAAA,kBAAkB,OAAO,aAAa,CAAC,CAAC;EACjD;EAEA,sBAAwD;GACtD,IAAI,KAAK,YAAY,KAAA,GAAW,OAAO,MAAM,oBAAoB;GACjE,IAAI,QAAQ,aAAa,KAAA,GACvB,OAAO,QAAQ,SAAS,SAAS,KAAK,GAAU,GAAG,KAAK,OAAO;GAEjE,OAAOC,4BAAAA,mBAAmB,QAAQ,SAAS,KAAK,GAAU,GAAG,EAAE,OAAO,KAAK,QAAQ,CAAC;EACtF;EAEA,eAAkC,OAAkD;GAClF,IAAI,QAAQ,aAAa,KAAA,GAAW,OAAO,QAAQ,SAAS,SAAS,KAAK,GAAU,GAAG,KAAK;GAC5F,OAAO,MAAM,cAAc,KAAA,IACvB,KAAA,IACAC,2BAAAA,kBAAkB,MAAM,WAAW,SAAS,KAAK,GAAU,CAAC,CAAC,eAAe;EAClF;EAEA,QAA2B,QAA8C;GACvE,MAAM,MAAM,SAAS,KAAK,GAAU;GACpC,IAAI,QAAQ,YAAY,KAAA,GAAW,OAAO,QAAQ,QAAQ,KAAK,MAAM;GACrE,OAAOL,4BAAAA,kBAAkB,QAAQ,GAAG;EACtC;EAEA,UAA0D;GAExD,OAAO,OAAO,WAAW,CAAC;EAC5B;CACF;CAEA,MAAM,QAAQ,MAAe,SAAS,QACpC,IAAI,SAAS,KAAK,UAAU,IAAI,GAAG;EACjC;EACA,SAAS,EAAE,gBAAgB,mBAAmB;CAChD,CAAC;CAEH,MAAM,cAAc,SAAkB,QACpC,IAAI,cAAc,KAAA,KAClB,QAAQ,QAAQ,IAAI,eAAe,MAAM,UAAU,IAAI;CAEzD,MAAM,UAAU,WAAW,KAAA,IAAY,KAAA,IAAYM,oBAAAA,iBAAiB,MAAM;CAQ1E,IAAI;CACJ,IAAI,UAAU;CACd,MAAM,kBAAkB,QAAwC;EAC9D,MAAM,WAAW,IAAI;EACrB,IAAI,aAAa,KAAA,KAAa,SAAS,OAAO,IAAI,OAAO,KAAA;EACzD,MAAM,UAAyB;GAAE,IAAI,SAAS;GAAI,WAAW,KAAK,MAAM,SAAS,SAAS;EAAE;EAC5F,IAAI,OAAO,MAAM,QAAQ,SAAS,GAAG,OAAO,KAAA;EAC5C,IAAI,aAAa,KAAA,KAAa,KAAK,IAAI,IAAI,SAAS,OAAO,KAAA;EAC3D,YAAY,YAAY;GACtB,MAAM,EAAE,OAAO,UAAU,MAAM,GAAG;GAClC,IAAI;IACF,IAAI,MAAM,sBAAsB,KAAA,GAAW;IAC3C,MAAM,OAAO,MAAM,MAAM,kBAAkB;IAC3C,IAAI,SAAS,KAAA,MAAc,KAAK,OAAO,QAAQ,MAAM,KAAK,aAAa,QAAQ,YAC7E;IAEF,KAAI,MADiBC,0BAAAA,aAAa,KAAK,OAAO,GAAG,OAAO,OAAO,EAAA,CACpD,SAAS,QAAQ,IAAI,sBAAsB,QAAQ,GAAG,SAAS;GAC5E,UAAU;IACR,MAAM,MAAM;GACd;EACF,EAAA,CAAG,CAAC,CAAC,OAAO,UAAmB;GAC7B,WAAW,KAAA;GACX,UAAU,KAAK,IAAI,IAAI;GACvB,QAAQ,MAAM,qCAAqC,QAAQ,GAAG,QAAQ,KAAK;EAC7E,CAAC;EACD,OAAO;CACT;CAEA,MAAM,QAAQ,OAAO,SAAkB,QAAa,QAA+B;EAEjF,IAAI;EACJ,IAAI;GACF,MAAM,SAAS,MAAM;EACvB,SAAS,OAAO;GACd,IAAI,EAAE,iBAAiB,qBAAqB,MAAM;GAClD,QAAQ,MAAM,cAAc,MAAM,SAAS;GAC3C,OAAO,KAAK,EAAE,OAAO,MAAM,QAAQ,GAAG,GAAG;EAC3C;EACA,MAAM,UAAU,eAAe,GAAG;EAElC,IAAI,YAAY,KAAA,GAAW;GACzB,IAAI,QAAQ,KAAA,GAAW,MAAM;QACxB,IAAI,UAAU,OAAO;EAC5B;EACA,MAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;EAC/B,MAAM,EAAE,OAAO,UAAU,MAAM,GAAG;EAClC,IAAI;GAEF,IAAI,IAAI,SAAS,WAAW,KAAK,KAAK,MAAM,cAAc,KAAA,GAAW;IACnE,MAAM,cAAc,OAAO,SAAS;IACpC,MAAM,UAAU,MAAMC,2BAAAA,eAAe,SAAS,MAAM,WAAW;KAC7D,aAAa,OAAO,QAAQ;KAC5B,SAAS,OAAO;KAChB,SAAS,OAAO,QAAQ,UAAU;KAClC,YACE,gBAAgB,KAAA,IACZ,KAAA,IACA,OAAO,EAAE,WAAW,QAAQ,SAAS;MACnC,MAAM,UAAmC,EAAE,YAAY,UAAU;MACjE,MAAM,WAAW,OAAO;MACxB,MAAM,UACJ,aAAa,KAAA,IAAY,KAAA,IAAYC,iBAAAA,WAAW,UAAU,aAAa,OAAO;MAChF,IAAI,YAAY,KAAA,KAAa,CAAC,QAAQ,IACpC,MAAM,IAAIC,iBAAAA,wBAAwB,aAAa,QAAQ,MAAM;MAG/D,MAAMC,0BAAAA,YACJ,KACA;OACE,IAAI,GAAG,YAAY,GAAG;OACtB;OACA,OAAO;OACP,SAAS,SAAS,QAAQ;OAC1B,IAAI;MACN,GACA,KACF;KACF;IACR,CAAC;IACD,IAAI,YAAY,KAAA,GAAW,OAAO;GACpC;GACA,IAAI,IAAI,aAAa,mBAAmB;IACtC,IAAI,CAAC,WAAW,SAAS,GAAG,GAAG,OAAO,KAAK,EAAE,OAAO,eAAe,GAAG,GAAG;IACzE,IAAI,QAAQ,WAAW,QAAQ,OAAO,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;IAC/E,OAAO,KAAK,MAAMC,0BAAAA,cAAc,KAAK,KAAK,CAAC;GAC7C;GACA,IAAI,IAAI,aAAa,WAAW;IAC9B,IAAI,CAAC,WAAW,SAAS,GAAG,GAAG,OAAO,KAAK,EAAE,OAAO,eAAe,GAAG,GAAG;IACzE,IAAI,QAAQ,WAAW,OAAO,OAAO,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;IAC9E,OAAO,KAAK,OAAO,CAAC;GACtB;GACA,IAAI,IAAI,aAAa,kBAAkB;IACrC,IAAI,CAAC,WAAW,SAAS,GAAG,GAAG,OAAO,KAAK,EAAE,OAAO,eAAe,GAAG,GAAG;IACzE,IAAI,QAAQ,WAAW,QAAQ,OAAO,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;IAC/E,IAAI;KACF,OAAO,KAAK,MAAML,0BAAAA,aAAa,KAAK,OAAO,GAAG,KAAK,CAAC;IACtD,SAAS,OAAO;KACd,IAAI,EAAE,iBAAiBM,oBAAAA,sBAAsB,MAAM;KACnD,OAAO,KAAK;MAAE,OAAO,MAAM;MAAS,UAAU,MAAM;KAAS,GAAG,GAAG;IACrE;GACF;GACA,IAAI,WAAW,KAAA,KAAa,YAAY,KAAA,KAAa,IAAI,SAAS,WAAW,WAAW,GAAG;IACzF,IAAI,CAAC,WAAW,SAAS,GAAG,GAAG,OAAO,KAAK,EAAE,OAAO,eAAe,GAAG,GAAG;IACzE,IAAI,QAAQ,WAAW,OAAO,OAAO,KAAK,EAAE,OAAO,qBAAqB,GAAG,GAAG;IAC9E,MAAM,MAAM,IAAI,SAAS,MAAM,CAAkB;IACjD,IAAI,CAAC,OAAO,OAAO,QAAQ,GAAG,GAC5B,OAAO,KAAK,EAAE,OAAO,2BAA2B,MAAM,GAAG,GAAG;IAE9D,IAAI,QAAiB,CAAC;IACtB,IAAI;KACF,QAAQ,KAAK,MAAM,IAAI,aAAa,IAAI,OAAO,KAAK,IAAI;IAC1D,QAAQ;KACN,OAAO,KAAK,EAAE,OAAO,8BAA8B,GAAG,GAAG;IAC3D;IACA,IAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,MAAM,QAAQ,KAAK,GACpE,OAAO,KAAK,EAAE,OAAO,8BAA8B,GAAG,GAAG;IAE3D,IAAI;KACF,MAAM,EAAE,SAAS,SAAS,MAAM,QAAQ,KAAK,KAAiC;KAC9E,OAAO,IAAI,SAAS,MAAM,EACxB,SAAS;MAAE,gBAAgB;MAA4B,aAAa;KAAQ,EAC9E,CAAC;IACH,SAAS,OAAO;KAEd,IAAI,iBAAiBC,kBAAAA,yBACnB,OAAO,KAAK;MAAE,OAAO,MAAM;MAAS,QAAQ,MAAM;KAAO,GAAG,GAAG;KAEjE,MAAM;IACR;GACF;GACA,OAAO,MAAMC,0BAAAA,cAAc,SAAS,KAAK,OAAO;IAC9C,UAAU,OAAO;IACjB,MAAM,QAAQ;GAChB,CAAC;EACH,UAAU;GAER,IAAI,QAAQ,KAAA,GAAW,MAAM,MAAM;QAC9B,IAAI,UAAU,MAAM,CAAC;EAC5B;CACF;CAEA,MAAM,QAAQ,OAAO,WAAgB;EACnC,MAAM,MAAM,SAAS,MAAM;EAC3B,MAAM,EAAE,OAAO,UAAU,MAAM,GAAG;EAClC,IAAI;GACF,OAAO,MAAMH,0BAAAA,cAAc,KAAK,KAAK;EACvC,UAAU;GACR,MAAM,MAAM;EACd;CACF;CAEA,MAAM,YAAY,OAAO,aAAsB,KAAU,QAA8B;EAErF,IAAI,WACD,YAAY;GACX,MAAM,eAAe,SAAS,GAAG,CAAC;GAClC,MAAM,MAAM,GAAG;EACjB,EAAA,CAAG,CACL;CACF;CAEA,OAAO;EAAE;EAAQ;EAAO;EAAO;CAAU;AAC3C"}
1
+ {"version":3,"file":"worker.cjs","names":["indexTemplates","z","compileConfig","d1Store","runtimeFromConfig","scaleSegmentWindows","JourneyRunner","registryValidator","channelsFromConfig","reportingObserver","registryRenderer","deployBundle","handleTracking","checkEvent","ContractValidationError","ingestEvent","sweepSegments","SchemaMismatchError","TemplateValidationError","handleRequest"],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport { type JourneyConfig, compileConfig } from '../config';\nimport { type Contract, ContractValidationError, checkEvent } from '../contract';\nimport { assertEmailConfig } from '../email';\nimport type { RegisteredAction } from '../engine/actions';\nimport type { JourneyObserver, MessageSender, MessageValidator } from '../engine/ports';\nimport { scaleSegmentWindows } from '../engine/time-scale';\nimport type { ScalarIR } from '../ir';\nimport { registryRenderer } from '../react-email';\nimport { reportingObserver } from '../reporting/observer';\nimport { handleTracking } from '../reporting/tracking';\nimport type { JourneyRuntimeOptions } from '../runtime';\nimport {\n type BundleVersion,\n type JourneyStore,\n SchemaMismatchError,\n type StoreLease,\n} from '../store/index';\nimport { DEFAULT_SCHEMA } from '../store/tables';\nimport { TemplateValidationError, indexTemplates, registryValidator } from '../templates';\nimport type { JourneyEnv, JourneyParams } from './bindings';\nimport { channelsFromConfig, runtimeFromConfig } from './channels';\nimport { d1Store } from './d1-env';\nimport {\n type SweepResult,\n deployBundle,\n handleRequest,\n ingestEvent,\n sweepSegments,\n} from './router';\nimport { JourneyRunner } from './runner';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\nexport interface JourneyWorkerOptions<Env extends JourneyEnv> {\n /**\n * The project's `workflow.config.ts` (`defineConfig`): what the Worker\n * deploys (`GET /bundle`, `POST /bundle/deploy` serve `compileConfig`), the\n * template registries the senders, the validator and `/preview` render from,\n * the contract every `/events` and `/identify` is checked against, the email\n * envelope, actions, and the runtime / APNs / FCM settings. Secrets come\n * from the env under the names `JourneyEnv` documents.\n */\n config: JourneyConfig;\n /**\n * The data plane, opened per request and per workflow run. Return a\n * `StoreLease` when the client must be released afterwards (a Postgres\n * connection); defaults to D1 + KV from the env's bindings. `database` is\n * the config's (`schema` defaulting to `workflow`), for a Postgres store:\n *\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database)\n */\n store?: (env: Env, database: { schema: string }) => JourneyStore | StoreLease;\n /**\n * Reporting hooks: node arrivals, every copy sent (with the tracking it should carry — click\n * links and an open pixel for email, payload data for a push), and how each instance ended.\n * By default a store that keeps reporting (Postgres) records them itself, and the Worker\n * serves the email tracking routes (`/t/...`, on `WORKFLOW_ORIGIN`); override to report\n * elsewhere.\n */\n observer?: (env: Env, store: JourneyStore) => JourneyObserver;\n /** Message outlet override (tests, a custom gateway); by default the channels the config describes. */\n messages?: (env: Env, store: JourneyStore) => MessageSender;\n /** Per-instance runtime options override (a tester in fast-forward); by default `config.runtime` for `ENVIRONMENT`. */\n runtime?: (env: Env, params: JourneyParams) => JourneyRuntimeOptions;\n /**\n * The host's environment, as a zod schema over its vars and secrets (bindings pass through\n * untouched): checked the first time the Worker runs, so a missing or malformed variable fails\n * there — every problem listed — not at the send that needs it. Webhook templates read their\n * URL from the parsed values: `webhook: (env: Env) => env.WEBHOOK_DISCORD_ALERTS`.\n */\n env?: core.$ZodType<object>;\n /** 'enforce' (default) answers an event or patch the contract refuses with a 400; 'report' only logs it. */\n contractMode?: 'enforce' | 'report';\n}\n\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 */\n/** The Worker's environment failed its schema (`journeyWorker({ env })`). */\nexport class EnvValidationError extends Error {\n override readonly name = 'EnvValidationError';\n constructor(readonly issues: readonly string[]) {\n super(`the Worker's environment is invalid:\\n ${issues.join('\\n ')}`);\n }\n}\n\nexport function journeyWorker<Env extends JourneyEnv>(\n options: JourneyWorkerOptions<Env>\n): JourneyWorker<Env> {\n const { config } = options;\n const emailIds = indexTemplates('email', config.templates?.email);\n // A config still on `emails` (its sender would be silently dropped) fails here, not at a send\n assertEmailConfig(config, emailIds.values());\n // Parsed once per env object: the runtime hands the same one to every request of an isolate\n const checked = new WeakMap<object, Env>();\n const checkEnv = (env: Env): Env => {\n if (options.env === undefined) return env;\n const cached = checked.get(env);\n if (cached !== undefined) return cached;\n const result = z.safeParse(options.env, env);\n if (!result.success) {\n // Names and reasons only: the values are secrets\n throw new EnvValidationError(\n result.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)\n );\n }\n const merged: Env = { ...env, ...result.data };\n checked.set(env, merged);\n return merged;\n };\n const bundle = () => compileConfig(config);\n const emails = config.templates?.email;\n const database = { schema: config.database?.schema ?? DEFAULT_SCHEMA };\n // In fast-forward, segment windows scale with the journeys reading them (time-scale.ts)\n const lease = (env: Env): Required<StoreLease> => {\n const opened = options.store === undefined ? d1Store(env) : options.store(env, database);\n const noop = async () => {};\n const factor = runtimeFromConfig(config, env).timeScale;\n return 'store' in opened\n ? { store: scaleSegmentWindows(opened.store, factor), close: opened.close ?? noop }\n : { store: scaleSegmentWindows(opened, factor), close: noop };\n };\n\n class Runner extends JourneyRunner {\n private readonly leases = new WeakMap<JourneyStore, () => Promise<void>>();\n private current: JourneyStore | undefined;\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 } = lease(checkEnv(this.env as Env));\n this.leases.set(store, close);\n this.current = store;\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 contract(): Contract | undefined {\n return config.contract;\n }\n\n protected override createMessageValidator(): MessageValidator {\n return registryValidator(config.templates ?? {});\n }\n\n protected override createMessageSender(): MessageSender {\n if (this.current === undefined) return super.createMessageSender();\n if (options.messages !== undefined) {\n return options.messages(checkEnv(this.env as Env), this.current);\n }\n return channelsFromConfig(config, checkEnv(this.env as Env), { store: this.current });\n }\n\n protected override createObserver(store: JourneyStore): JourneyObserver | undefined {\n if (options.observer !== undefined) return options.observer(checkEnv(this.env as Env), store);\n return store.reporting === undefined\n ? undefined\n : reportingObserver(store.reporting, checkEnv(this.env as Env).WORKFLOW_ORIGIN);\n }\n\n protected override runtime(params: JourneyParams): JourneyRuntimeOptions {\n const env = checkEnv(this.env as Env);\n if (options.runtime !== undefined) return options.runtime(env, params);\n return runtimeFromConfig(config, env);\n }\n\n protected override actions(): readonly RegisteredAction[] {\n // An action ref is a registered action structurally: the same object carries name, code and hash\n return config.actions ?? [];\n }\n }\n\n const json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { 'content-type': 'application/json' },\n });\n\n const authorized = (request: Request, env: Env): boolean =>\n env.API_TOKEN === undefined ||\n request.headers.get('authorization') === `Bearer ${env.API_TOKEN}`;\n\n const preview = emails === undefined ? undefined : registryRenderer(emails, config.email);\n\n // The bundle compiled into this version goes live on its own, once per isolate: the first\n // request or cron run reads which version's bundle is live (one row) and applies this one only\n // over an older version, so one still serving during a rollout can never put its routes back.\n // A failure (say the migrations have not run) is logged and retried a minute later. Only the\n // request that starts the check gets its promise back: a request context cannot wait on\n // another's I/O.\n let applying: Promise<void> | undefined;\n let retryAt = 0;\n const applyOwnBundle = (env: Env): Promise<void> | undefined => {\n const metadata = env.CF_VERSION_METADATA;\n if (metadata === undefined || metadata.id === '') return undefined;\n const version: BundleVersion = { id: metadata.id, createdAt: Date.parse(metadata.timestamp) };\n if (Number.isNaN(version.createdAt)) return undefined;\n if (applying !== undefined || Date.now() < retryAt) return undefined;\n applying = (async () => {\n const { store, close } = lease(env);\n try {\n if (store.liveBundleVersion === undefined) return; // D1: POST /bundle/deploy\n const live = await store.liveBundleVersion();\n if (live !== undefined && (live.id === version.id || live.createdAt >= version.createdAt))\n return;\n const result = await deployBundle(env, bundle(), store, version);\n if (result.applied) console.log(`[workflow] version ${version.id} is live`);\n } finally {\n await close();\n }\n })().catch((error: unknown) => {\n applying = undefined;\n retryAt = Date.now() + 60_000;\n console.error(`[workflow] could not make version ${version.id} live`, error);\n });\n return applying;\n };\n\n const fetch = async (request: Request, rawEnv: Env, ctx?: ExecutionContextLike) => {\n // Everything below reads the validated environment (the schema's defaults applied)\n let env: Env;\n try {\n env = checkEnv(rawEnv);\n } catch (error) {\n if (!(error instanceof EnvValidationError)) throw error;\n console.error(`[workflow] ${error.message}`);\n return json({ error: error.message }, 500);\n }\n const pending = applyOwnBundle(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 url = new URL(request.url);\n const { store, close } = lease(env);\n try {\n // Opened by recipients, so no bearer: the routes only record, and redirect to recorded URLs\n if (url.pathname.startsWith('/t/') && store.reporting !== undefined) {\n const openedEvent = config.webPush?.openedEvent;\n const tracked = await handleTracking(request, store.reporting, {\n legacyUnsubscribeUrl: config.email?.unsubscribeUrl,\n product: config.title,\n quickMs: config.email?.tracking?.quickClickMs,\n pushOpened:\n openedEvent === undefined\n ? undefined\n : async ({ messageId, userId, at }) => {\n const payload: Record<string, unknown> = { message_id: messageId };\n const contract = config.contract;\n const verdict =\n contract === undefined ? undefined : checkEvent(contract, openedEvent, payload);\n if (verdict !== undefined && !verdict.ok) {\n throw new ContractValidationError(openedEvent, verdict.issues);\n }\n // Keyed by the copy: a second tap of the same notification is the same event\n await ingestEvent(\n env,\n {\n id: `${openedEvent}:${messageId}`,\n userId,\n event: openedEvent,\n payload: verdict?.data ?? payload,\n ts: at,\n },\n store\n );\n },\n });\n if (tracked !== undefined) return tracked;\n }\n if (url.pathname === '/segments/sweep') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'POST') return json({ error: 'method not allowed' }, 405);\n return json(await sweepSegments(env, store));\n }\n if (url.pathname === '/bundle') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'GET') return json({ error: 'method not allowed' }, 405);\n return json(bundle());\n }\n if (url.pathname === '/bundle/deploy') {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'POST') return json({ error: 'method not allowed' }, 405);\n try {\n return json(await deployBundle(env, bundle(), store));\n } catch (error) {\n if (!(error instanceof SchemaMismatchError)) throw error;\n return json({ error: error.message, problems: error.problems }, 503);\n }\n }\n if (emails !== undefined && preview !== undefined && url.pathname.startsWith('/preview/')) {\n if (!authorized(request, env)) return json({ error: 'unauthorized' }, 401);\n if (request.method !== 'GET') return json({ error: 'method not allowed' }, 405);\n const key = url.pathname.slice('/preview/'.length);\n if (!emailIds.has(key)) return json({ error: `unknown email template: ${key}` }, 404);\n // `?props=<JSON object>`: JSON keeps numbers and booleans typed, which query strings cannot\n let props: unknown = {};\n try {\n props = JSON.parse(url.searchParams.get('props') ?? '{}');\n } catch {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n if (typeof props !== 'object' || props === null || Array.isArray(props)) {\n return json({ error: 'props must be a JSON object' }, 400);\n }\n try {\n const { subject, html } = await preview(key, props as Record<string, ScalarIR>);\n return new Response(html, {\n headers: { 'content-type': 'text/html; charset=utf-8', 'x-subject': subject },\n });\n } catch (error) {\n // The query string is the props: a refusal is the caller's to fix, with the field paths\n if (error instanceof TemplateValidationError) {\n return json({ error: error.message, issues: error.issues }, 400);\n }\n throw error;\n }\n }\n return await handleRequest(request, env, store, {\n contract: config.contract,\n mode: options.contractMode,\n });\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 = async (rawEnv: Env) => {\n const env = checkEnv(rawEnv);\n const { store, close } = lease(env);\n try {\n return await sweepSegments(env, store);\n } finally {\n await close();\n }\n };\n\n const scheduled = async (_controller: unknown, env: Env, ctx: ExecutionContextLike) => {\n // A version with no traffic yet goes live here; the sweep then reads its segments\n ctx.waitUntil(\n (async () => {\n await applyOwnBundle(checkEnv(env));\n await sweep(env);\n })()\n );\n };\n\n return { Runner, fetch, sweep, scheduled };\n}\n"],"mappings":"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"}
@@ -1,10 +1,10 @@
1
1
  import { JourneyObserver, MessageSender } from "../engine/ports.cjs";
2
- import { JourneyRuntimeOptions } from "../runtime.cjs";
3
- import { JourneyConfig } from "../config.cjs";
4
2
  import { JourneyEnv, JourneyParams } from "./bindings.cjs";
5
3
  import { JourneyStore, StoreLease } from "../store/index.cjs";
6
4
  import { SweepResult } from "./router.cjs";
5
+ import { JourneyRuntimeOptions } from "../runtime.cjs";
7
6
  import { JourneyRunner } from "./runner.cjs";
7
+ import { JourneyConfig } from "../config.cjs";
8
8
  import * as core from "zod/v4/core";
9
9
  //#region src/cloudflare/worker.d.ts
10
10
  /** The fetch handler's execution context (the subset used). */
@@ -1 +1 @@
1
- {"version":3,"file":"worker.d.cts","names":[],"sources":["../../src/cloudflare/worker.ts"],"mappings":";;;;;;;;;;iBAiCiB;EACf,UAAU,SAAS;;iBAGJ,qBAAqB,YAAY;;;;;;;;;EAShD,QAAQ;;;;;;;;;EASR,SAAS,KAAK,KAAK;IAAY;QAAqB,eAAe;;;;;;;;EAQnE,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,WAAW,KAAK,KAAK,QAAQ,kBAAkB;;;;;;;EAO/C,MAAM,KAAK;;EAEX;;iBAGe,cAAc,YAAY;;EAEzC,gBAAgB,kBAAkB;;EAElC,QAAQ,SAAS,SAAS,KAAK,KAAK,MAAM,yBAAyB,QAAQ;;;;;;;EAO3E,QAAQ,KAAK,QAAQ,QAAQ;;EAE7B,YAAY,qBAAqB,KAAK,KAAK,KAAK,yBAAyB;;;;;;;;;;;;;;;qBAgB9D,2BAA2B;WAEjB;WADH;EAClB,YAAqB;;wBAKP,cAAc,YAAY,YACxC,SAAS,qBAAqB,OAC7B,cAAc"}
1
+ {"version":3,"file":"worker.d.cts","names":[],"sources":["../../src/cloudflare/worker.ts"],"mappings":";;;;;;;;;;iBAkCiB;EACf,UAAU,SAAS;;iBAGJ,qBAAqB,YAAY;;;;;;;;;EAShD,QAAQ;;;;;;;;;EASR,SAAS,KAAK,KAAK;IAAY;QAAqB,eAAe;;;;;;;;EAQnE,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,WAAW,KAAK,KAAK,QAAQ,kBAAkB;;;;;;;EAO/C,MAAM,KAAK;;EAEX;;iBAGe,cAAc,YAAY;;EAEzC,gBAAgB,kBAAkB;;EAElC,QAAQ,SAAS,SAAS,KAAK,KAAK,MAAM,yBAAyB,QAAQ;;;;;;;EAO3E,QAAQ,KAAK,QAAQ,QAAQ;;EAE7B,YAAY,qBAAqB,KAAK,KAAK,KAAK,yBAAyB;;;;;;;;;;;;;;;qBAgB9D,2BAA2B;WAEjB;WADH;EAClB,YAAqB;;wBAKP,cAAc,YAAY,YACxC,SAAS,qBAAqB,OAC7B,cAAc"}
@@ -1,10 +1,10 @@
1
1
  import { JourneyObserver, MessageSender } from "../engine/ports.mjs";
2
- import { JourneyRuntimeOptions } from "../runtime.mjs";
3
- import { JourneyConfig } from "../config.mjs";
4
2
  import { JourneyEnv, JourneyParams } from "./bindings.mjs";
5
3
  import { JourneyStore, StoreLease } from "../store/index.mjs";
6
4
  import { SweepResult } from "./router.mjs";
5
+ import { JourneyRuntimeOptions } from "../runtime.mjs";
7
6
  import { JourneyRunner } from "./runner.mjs";
7
+ import { JourneyConfig } from "../config.mjs";
8
8
  import * as core from "zod/v4/core";
9
9
  //#region src/cloudflare/worker.d.ts
10
10
  /** The fetch handler's execution context (the subset used). */
@@ -1 +1 @@
1
- {"version":3,"file":"worker.d.mts","names":[],"sources":["../../src/cloudflare/worker.ts"],"mappings":";;;;;;;;;;iBAiCiB;EACf,UAAU,SAAS;;iBAGJ,qBAAqB,YAAY;;;;;;;;;EAShD,QAAQ;;;;;;;;;EASR,SAAS,KAAK,KAAK;IAAY;QAAqB,eAAe;;;;;;;;EAQnE,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,WAAW,KAAK,KAAK,QAAQ,kBAAkB;;;;;;;EAO/C,MAAM,KAAK;;EAEX;;iBAGe,cAAc,YAAY;;EAEzC,gBAAgB,kBAAkB;;EAElC,QAAQ,SAAS,SAAS,KAAK,KAAK,MAAM,yBAAyB,QAAQ;;;;;;;EAO3E,QAAQ,KAAK,QAAQ,QAAQ;;EAE7B,YAAY,qBAAqB,KAAK,KAAK,KAAK,yBAAyB;;;;;;;;;;;;;;;qBAgB9D,2BAA2B;WAEjB;WADH;EAClB,YAAqB;;wBAKP,cAAc,YAAY,YACxC,SAAS,qBAAqB,OAC7B,cAAc"}
1
+ {"version":3,"file":"worker.d.mts","names":[],"sources":["../../src/cloudflare/worker.ts"],"mappings":";;;;;;;;;;iBAkCiB;EACf,UAAU,SAAS;;iBAGJ,qBAAqB,YAAY;;;;;;;;;EAShD,QAAQ;;;;;;;;;EASR,SAAS,KAAK,KAAK;IAAY;QAAqB,eAAe;;;;;;;;EAQnE,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,YAAY,KAAK,KAAK,OAAO,iBAAiB;;EAE9C,WAAW,KAAK,KAAK,QAAQ,kBAAkB;;;;;;;EAO/C,MAAM,KAAK;;EAEX;;iBAGe,cAAc,YAAY;;EAEzC,gBAAgB,kBAAkB;;EAElC,QAAQ,SAAS,SAAS,KAAK,KAAK,MAAM,yBAAyB,QAAQ;;;;;;;EAO3E,QAAQ,KAAK,QAAQ,QAAQ;;EAE7B,YAAY,qBAAqB,KAAK,KAAK,KAAK,yBAAyB;;;;;;;;;;;;;;;qBAgB9D,2BAA2B;WAEjB;WADH;EAClB,YAAqB;;wBAKP,cAAc,YAAY,YACxC,SAAS,qBAAqB,OAC7B,cAAc"}
@@ -1,4 +1,5 @@
1
- import { TemplateValidationError, registryValidator } from "../templates.mjs";
1
+ import { assertEmailConfig } from "../email.mjs";
2
+ import { TemplateValidationError, indexTemplates, registryValidator } from "../templates.mjs";
2
3
  import { compileConfig } from "../config.mjs";
3
4
  import { ContractValidationError, checkEvent } from "../contract.mjs";
4
5
  import "../store/tables.mjs";
@@ -36,6 +37,8 @@ var EnvValidationError = class extends Error {
36
37
  };
37
38
  function journeyWorker(options) {
38
39
  const { config } = options;
40
+ const emailIds = indexTemplates("email", config.templates?.email);
41
+ assertEmailConfig(config, emailIds.values());
39
42
  const checked = /* @__PURE__ */ new WeakMap();
40
43
  const checkEnv = (env) => {
41
44
  if (options.env === void 0) return env;
@@ -106,7 +109,7 @@ function journeyWorker(options) {
106
109
  headers: { "content-type": "application/json" }
107
110
  });
108
111
  const authorized = (request, env) => env.API_TOKEN === void 0 || request.headers.get("authorization") === `Bearer ${env.API_TOKEN}`;
109
- const preview = emails === void 0 ? void 0 : registryRenderer(emails);
112
+ const preview = emails === void 0 ? void 0 : registryRenderer(emails, config.email);
110
113
  let applying;
111
114
  let retryAt = 0;
112
115
  const applyOwnBundle = (env) => {
@@ -155,9 +158,9 @@ function journeyWorker(options) {
155
158
  if (url.pathname.startsWith("/t/") && store.reporting !== void 0) {
156
159
  const openedEvent = config.webPush?.openedEvent;
157
160
  const tracked = await handleTracking(request, store.reporting, {
158
- unsubscribe: config.emails?.unsubscribe,
161
+ legacyUnsubscribeUrl: config.email?.unsubscribeUrl,
159
162
  product: config.title,
160
- quickMs: config.emails?.tracking?.quickClickMs,
163
+ quickMs: config.email?.tracking?.quickClickMs,
161
164
  pushOpened: openedEvent === void 0 ? void 0 : async ({ messageId, userId, at }) => {
162
165
  const payload = { message_id: messageId };
163
166
  const contract = config.contract;
@@ -201,7 +204,7 @@ function journeyWorker(options) {
201
204
  if (!authorized(request, env)) return json({ error: "unauthorized" }, 401);
202
205
  if (request.method !== "GET") return json({ error: "method not allowed" }, 405);
203
206
  const key = url.pathname.slice(9);
204
- if (!Object.hasOwn(emails, key)) return json({ error: `unknown email template: ${key}` }, 404);
207
+ if (!emailIds.has(key)) return json({ error: `unknown email template: ${key}` }, 404);
205
208
  let props = {};
206
209
  try {
207
210
  props = JSON.parse(url.searchParams.get("props") ?? "{}");