@shware/workflow 0.4.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 (162) hide show
  1. package/README.md +90 -49
  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 +13 -18
  9. package/dist/cloudflare/channels.cjs.map +1 -1
  10. package/dist/cloudflare/channels.d.cts +3 -3
  11. package/dist/cloudflare/channels.d.cts.map +1 -1
  12. package/dist/cloudflare/channels.d.mts +3 -3
  13. package/dist/cloudflare/channels.d.mts.map +1 -1
  14. package/dist/cloudflare/channels.mjs +14 -19
  15. package/dist/cloudflare/channels.mjs.map +1 -1
  16. package/dist/cloudflare/index.d.cts +4 -4
  17. package/dist/cloudflare/index.d.mts +4 -4
  18. package/dist/cloudflare/router.d.cts +1 -1
  19. package/dist/cloudflare/router.d.mts +1 -1
  20. package/dist/cloudflare/runner.d.cts +2 -2
  21. package/dist/cloudflare/runner.d.mts +2 -2
  22. package/dist/cloudflare/senders.cjs +15 -8
  23. package/dist/cloudflare/senders.cjs.map +1 -1
  24. package/dist/cloudflare/senders.d.cts +15 -11
  25. package/dist/cloudflare/senders.d.cts.map +1 -1
  26. package/dist/cloudflare/senders.d.mts +15 -11
  27. package/dist/cloudflare/senders.d.mts.map +1 -1
  28. package/dist/cloudflare/senders.mjs +15 -8
  29. package/dist/cloudflare/senders.mjs.map +1 -1
  30. package/dist/cloudflare/worker.cjs +7 -4
  31. package/dist/cloudflare/worker.cjs.map +1 -1
  32. package/dist/cloudflare/worker.d.cts +2 -2
  33. package/dist/cloudflare/worker.d.cts.map +1 -1
  34. package/dist/cloudflare/worker.d.mts +2 -2
  35. package/dist/cloudflare/worker.d.mts.map +1 -1
  36. package/dist/cloudflare/worker.mjs +8 -5
  37. package/dist/cloudflare/worker.mjs.map +1 -1
  38. package/dist/config.cjs +66 -42
  39. package/dist/config.cjs.map +1 -1
  40. package/dist/config.d.cts +26 -45
  41. package/dist/config.d.cts.map +1 -1
  42. package/dist/config.d.mts +26 -45
  43. package/dist/config.d.mts.map +1 -1
  44. package/dist/config.mjs +67 -42
  45. package/dist/config.mjs.map +1 -1
  46. package/dist/dsl/action.cjs +1 -1
  47. package/dist/dsl/action.cjs.map +1 -1
  48. package/dist/dsl/action.d.cts +1 -1
  49. package/dist/dsl/action.d.mts +1 -1
  50. package/dist/dsl/action.mjs +1 -1
  51. package/dist/dsl/action.mjs.map +1 -1
  52. package/dist/dsl/bundle.cjs +3 -2
  53. package/dist/dsl/bundle.cjs.map +1 -1
  54. package/dist/dsl/bundle.d.cts +3 -2
  55. package/dist/dsl/bundle.d.cts.map +1 -1
  56. package/dist/dsl/bundle.d.mts +3 -2
  57. package/dist/dsl/bundle.d.mts.map +1 -1
  58. package/dist/dsl/bundle.mjs +3 -2
  59. package/dist/dsl/bundle.mjs.map +1 -1
  60. package/dist/dsl/condition.cjs +1 -1
  61. package/dist/dsl/condition.cjs.map +1 -1
  62. package/dist/dsl/condition.d.cts +1 -1
  63. package/dist/dsl/condition.d.mts +1 -1
  64. package/dist/dsl/condition.mjs +1 -1
  65. package/dist/dsl/condition.mjs.map +1 -1
  66. package/dist/dsl/flow.cjs +45 -17
  67. package/dist/dsl/flow.cjs.map +1 -1
  68. package/dist/dsl/flow.d.cts +30 -17
  69. package/dist/dsl/flow.d.cts.map +1 -1
  70. package/dist/dsl/flow.d.mts +30 -17
  71. package/dist/dsl/flow.d.mts.map +1 -1
  72. package/dist/dsl/flow.mjs +45 -18
  73. package/dist/dsl/flow.mjs.map +1 -1
  74. package/dist/dsl/index.cjs +0 -1
  75. package/dist/dsl/index.d.cts +2 -2
  76. package/dist/dsl/index.d.mts +2 -2
  77. package/dist/dsl/index.mjs +2 -2
  78. package/dist/dsl/template.cjs +2 -47
  79. package/dist/dsl/template.cjs.map +1 -1
  80. package/dist/dsl/template.d.cts +4 -54
  81. package/dist/dsl/template.d.cts.map +1 -1
  82. package/dist/dsl/template.d.mts +4 -54
  83. package/dist/dsl/template.d.mts.map +1 -1
  84. package/dist/dsl/template.mjs +3 -47
  85. package/dist/dsl/template.mjs.map +1 -1
  86. package/dist/dsl/workflow.cjs +1 -1
  87. package/dist/dsl/workflow.cjs.map +1 -1
  88. package/dist/dsl/workflow.d.cts +1 -1
  89. package/dist/dsl/workflow.d.mts +1 -1
  90. package/dist/dsl/workflow.mjs +1 -1
  91. package/dist/dsl/workflow.mjs.map +1 -1
  92. package/dist/email-context.cjs +29 -0
  93. package/dist/email-context.cjs.map +1 -0
  94. package/dist/email-context.d.cts +11 -0
  95. package/dist/email-context.d.cts.map +1 -0
  96. package/dist/email-context.d.mts +11 -0
  97. package/dist/email-context.d.mts.map +1 -0
  98. package/dist/email-context.mjs +27 -0
  99. package/dist/email-context.mjs.map +1 -0
  100. package/dist/email.cjs +72 -0
  101. package/dist/email.cjs.map +1 -0
  102. package/dist/email.d.cts +63 -0
  103. package/dist/email.d.cts.map +1 -0
  104. package/dist/email.d.mts +63 -0
  105. package/dist/email.d.mts.map +1 -0
  106. package/dist/email.mjs +65 -0
  107. package/dist/email.mjs.map +1 -0
  108. package/dist/engine/index.d.cts +2 -2
  109. package/dist/engine/index.d.mts +2 -2
  110. package/dist/engine/ports.d.cts +7 -2
  111. package/dist/engine/ports.d.cts.map +1 -1
  112. package/dist/engine/ports.d.mts +7 -2
  113. package/dist/engine/ports.d.mts.map +1 -1
  114. package/dist/index.cjs +13 -3
  115. package/dist/index.d.cts +5 -4
  116. package/dist/index.d.mts +5 -4
  117. package/dist/index.mjs +5 -4
  118. package/dist/push.cjs +3 -5
  119. package/dist/push.cjs.map +1 -1
  120. package/dist/push.d.cts +2 -2
  121. package/dist/push.d.cts.map +1 -1
  122. package/dist/push.d.mts +2 -2
  123. package/dist/push.d.mts.map +1 -1
  124. package/dist/push.mjs +4 -6
  125. package/dist/push.mjs.map +1 -1
  126. package/dist/react-email.cjs +16 -8
  127. package/dist/react-email.cjs.map +1 -1
  128. package/dist/react-email.d.cts +2 -1
  129. package/dist/react-email.d.cts.map +1 -1
  130. package/dist/react-email.d.mts +2 -1
  131. package/dist/react-email.d.mts.map +1 -1
  132. package/dist/react-email.mjs +17 -9
  133. package/dist/react-email.mjs.map +1 -1
  134. package/dist/reporting/classify.cjs.map +1 -1
  135. package/dist/reporting/classify.d.cts +1 -1
  136. package/dist/reporting/classify.d.mts +1 -1
  137. package/dist/reporting/classify.mjs.map +1 -1
  138. package/dist/reporting/observer.cjs +5 -4
  139. package/dist/reporting/observer.cjs.map +1 -1
  140. package/dist/reporting/observer.d.cts +3 -2
  141. package/dist/reporting/observer.d.cts.map +1 -1
  142. package/dist/reporting/observer.d.mts +3 -2
  143. package/dist/reporting/observer.d.mts.map +1 -1
  144. package/dist/reporting/observer.mjs +5 -4
  145. package/dist/reporting/observer.mjs.map +1 -1
  146. package/dist/reporting/tracking.cjs +3 -12
  147. package/dist/reporting/tracking.cjs.map +1 -1
  148. package/dist/reporting/tracking.d.cts +18 -7
  149. package/dist/reporting/tracking.d.cts.map +1 -1
  150. package/dist/reporting/tracking.d.mts +18 -7
  151. package/dist/reporting/tracking.d.mts.map +1 -1
  152. package/dist/reporting/tracking.mjs +3 -12
  153. package/dist/reporting/tracking.mjs.map +1 -1
  154. package/dist/templates.cjs +90 -30
  155. package/dist/templates.cjs.map +1 -1
  156. package/dist/templates.d.cts +70 -13
  157. package/dist/templates.d.cts.map +1 -1
  158. package/dist/templates.d.mts +70 -13
  159. package/dist/templates.d.mts.map +1 -1
  160. package/dist/templates.mjs +87 -31
  161. package/dist/templates.mjs.map +1 -1
  162. package/package.json +11 -1
package/README.md CHANGED
@@ -9,11 +9,11 @@ data in D1 or in your own Postgres.
9
9
 
10
10
  ```ts
11
11
  const welcome = workflow({ id: 'welcome', trigger: trigger.event(e.sign_up) })
12
- .email(t.email.getting_started, { name: u.name })
12
+ .email(email.getting_started, { name: u.name })
13
13
  .delay('3 days')
14
14
  .waitUntil(performed(e.activated), {
15
15
  timeout: '7 days',
16
- onTimeout: (w) => w.email(t.email.nudge, { name: u.name }),
16
+ onTimeout: (w) => w.email(email.nudge, { name: u.name }),
17
17
  });
18
18
  ```
19
19
 
@@ -68,9 +68,11 @@ the contract is defined, because input and output must mean the same JSON.
68
68
 
69
69
  ### 2. Templates
70
70
 
71
- One object per template. Its strict Zod `props` schema is the only source of
72
- the props type: the DSL's arguments, the `preview` and the component are all
73
- derived from it, and every send parses the props against it first.
71
+ One object per template, identified by its own `id` (unique per channel): the
72
+ IR, the manifest, every send and the studio name it by that id, so keep it
73
+ stable and rename `title` instead. Its strict Zod `props` schema is the only
74
+ source of the props type: the DSL's arguments, the `preview` and the component
75
+ are all derived from it, and every send parses the props against it first.
74
76
 
75
77
  ```tsx
76
78
  // src/email/welcome.tsx
@@ -79,6 +81,8 @@ import { emailTemplate } from '@shware/workflow';
79
81
  import { strictObject, string } from 'zod/mini';
80
82
 
81
83
  export default emailTemplate({
84
+ id: 'welcome',
85
+ from: { email: 'noreply@acme.test', name: 'Acme' },
82
86
  title: 'Welcome',
83
87
  props: strictObject({ name: string() }),
84
88
  subject: 'Welcome aboard',
@@ -91,30 +95,37 @@ export default emailTemplate({
91
95
  });
92
96
  ```
93
97
 
94
- Register templates once, per channel, and derive the references the workflows
95
- use from the same registry:
98
+ Gather them per channel; the workflows send the definitions and the config
99
+ registers the whole:
100
+
101
+ ```ts
102
+ // src/email/index.ts
103
+ import tip from './tip';
104
+ import welcome from './welcome';
105
+
106
+ export const email = { welcome, tip };
107
+ ```
96
108
 
97
109
  ```ts
98
110
  // src/templates.ts
99
- import { templates } from '@shware/workflow';
100
- import welcome from './email/welcome';
101
- import tip from './email/tip';
111
+ import { email } from './email';
102
112
 
103
- export const registry = { email: { welcome, tip } };
104
- export const t = templates(registry);
113
+ export const templates = { email };
105
114
  ```
106
115
 
107
- `tip.tsx` is written the same way. The registry types the keys: `t.email.typo` does not compile, a push template
108
- is not accepted where an email is, and `.email(t.email.welcome, props)` checks
109
- `props` against the template's schema.
116
+ `tip.tsx` is written the same way. The property names are JavaScript names
117
+ only. `.email(email.welcome, props)` checks `props` against that template's
118
+ schema, and a push template is not accepted where an email is. The compile
119
+ refuses an id registered twice in a channel, and a workflow sending a
120
+ definition other than the one registered under its id.
110
121
 
111
122
  ### 3. A workflow
112
123
 
113
124
  ```ts
114
125
  // src/workflows/welcome.ts
115
126
  import { not, performed, trigger, workflow } from '@shware/workflow';
116
- import { t } from '../templates';
117
127
  import { e, u } from '../contract';
128
+ import { email } from '../email';
118
129
 
119
130
  export const welcomeSeries = workflow({
120
131
  id: 'welcome_series',
@@ -122,9 +133,9 @@ export const welcomeSeries = workflow({
122
133
  trigger: trigger.event(e.login),
123
134
  goal: { condition: performed(e.purchase), within: '7 days' },
124
135
  })
125
- .email(t.email.welcome, { name: u.name })
136
+ .email(email.welcome, { name: u.name })
126
137
  .delay('2 days')
127
- .branch([not(performed(e.purchase)), (w) => w.email(t.email.tip, { name: u.name })]);
138
+ .branch([not(performed(e.purchase)), (w) => w.email(email.tip, { name: u.name })]);
128
139
  ```
129
140
 
130
141
  `id` is wire identity (instances, the entry ledger and stats are keyed on it),
@@ -136,15 +147,15 @@ so rename `name` instead.
136
147
  // workflow.config.ts
137
148
  import { defineConfig } from '@shware/workflow';
138
149
  import { contract } from './src/contract';
139
- import { registry } from './src/templates';
150
+ import { templates } from './src/templates';
140
151
  import { welcomeSeries } from './src/workflows/welcome';
141
152
 
142
153
  export default defineConfig({
143
154
  title: 'Workflow Studio · Acme',
144
155
  contract,
145
156
  workflows: [welcomeSeries],
146
- templates: registry,
147
- emails: { from: { email: 'noreply@acme.test', name: 'Acme' } },
157
+ templates,
158
+ email: { unsubscribeUrl: 'https://acme.test/unsubscribe' },
148
159
  runtime: {
149
160
  development: { timeScale: 600, logMessages: true },
150
161
  production: { timeScale: 1, logMessages: false },
@@ -212,8 +223,8 @@ the routing table. Run it after every deploy that changes a journey.
212
223
  From the service that knows what users do:
213
224
 
214
225
  ```ts
215
- import { JourneyClient } from '@shware/workflow/client';
216
226
  import { contract } from '@acme/workflow-contract';
227
+ import { JourneyClient } from '@shware/workflow/client';
217
228
 
218
229
  export const journeys = new JourneyClient({
219
230
  contract,
@@ -296,10 +307,10 @@ trigger.webhook();
296
307
 
297
308
  | Step | What it does |
298
309
  | --------------------------------------------- | ------------------------------------------------------------------------- |
299
- | `.email(t.email.k, props)` | Send an email |
300
- | `.push(t.push.k, props, { devices? })` | Push to every device of the user, or only the `'latest'`; skipped if none |
301
- | `.slack(t.slack.k, props)` | Post to a Slack incoming webhook |
302
- | `.discord(t.discord.k, props)` | Post to a Discord webhook |
310
+ | `.email(email.k, props)` | Send an email |
311
+ | `.push(push.k, props, { devices? })` | Push to every device of the user, or only the `'latest'`; skipped if none |
312
+ | `.slack(slack.k, props)` | Post to a Slack incoming webhook |
313
+ | `.discord(discord.k, props)` | Post to a Discord webhook |
303
314
  | `.sms()`, `.inApp()`, `.survey()` | Message nodes with no built-in sender (see [Channels](#channels)) |
304
315
  | `.prefer([first, second], props, { label? })` | One push and one email: the first the user is reachable on |
305
316
  | `.delay('1 day')` | Sleep; `{ min, max }` sleeps a random time in between |
@@ -323,9 +334,9 @@ Both templates must declare the same props type.
323
334
 
324
335
  ```ts
325
336
  workflow({ id: 'welcome_series', trigger: trigger.event(e.login) })
326
- .email(t.email.welcome, { name: u.name })
337
+ .email(email.welcome, { name: u.name })
327
338
  .delay('1 day')
328
- .prefer([t.push.tip_push, t.email.tip], { name: u.name }, { label: 'tip by channel' });
339
+ .prefer([push.tip_push, email.tip], { name: u.name }, { label: 'tip by channel' });
329
340
  ```
330
341
 
331
342
  **timeWindow** takes `between: ['09:00', '17:00']` (24-hour, leading zeros, not
@@ -345,7 +356,7 @@ workflow({ id: 'notification_optin', trigger: trigger.event(e.login) }).waitUnti
345
356
  ),
346
357
  {
347
358
  timeout: '1 day',
348
- onTimeout: (w) => w.email(t.email.enable_notifications, { name: u.name }),
359
+ onTimeout: (w) => w.email(email.enable_notifications, { name: u.name }),
349
360
  }
350
361
  );
351
362
  ```
@@ -359,11 +370,11 @@ workflow({ id: 'nps_followup', trigger: trigger.event(e.nps_sent) }).branch(
359
370
  'by score',
360
371
  [
361
372
  performed(e.nps_sent, (p) => gte(p.score, 9)),
362
- (w) => w.delay('1 day').email(t.email.review_ask, { name: u.name }),
373
+ (w) => w.delay('1 day').email(email.review_ask, { name: u.name }),
363
374
  ],
364
375
  [
365
376
  performed(e.nps_sent, (p) => lte(p.score, 6)),
366
- (w) => w.slack(t.slack.nps_detractor, { name: u.name }).exit('handed to support'),
377
+ (w) => w.slack(slack.nps_detractor, { name: u.name }).exit('handed to support'),
367
378
  ]
368
379
  );
369
380
  ```
@@ -378,8 +389,8 @@ workflow({ id: 'checkout_recovery', trigger: trigger.event(e.begin_checkout) })
378
389
  .delay('1 day')
379
390
  .cohort(
380
391
  {
381
- discount: { weight: 50, flow: (w) => w.email(t.email.offer_discount, { name: u.name }) },
382
- social_proof: { weight: 50, flow: (w) => w.email(t.email.offer_reviews, { name: u.name }) },
392
+ discount: { weight: 50, flow: (w) => w.email(email.offer_discount, { name: u.name }) },
393
+ social_proof: { weight: 50, flow: (w) => w.email(email.offer_reviews, { name: u.name }) },
383
394
  },
384
395
  { key: 'checkout_offer_v1' }
385
396
  );
@@ -450,32 +461,61 @@ the compile fails.
450
461
 
451
462
  ### Email
452
463
 
453
- `emailTemplate({ title, description?, props, subject, preview, component })`
464
+ `emailTemplate({ id, from, replyTo?, unsubscribeUrl?, title, description?, props, subject, preview, component })`
454
465
  declares one. The Worker renders it with `@shware/workflow/react-email` (React's
455
466
  streaming renderer, which waits for react-email's `<Tailwind>`) and sends it
456
- through the `EMAIL` send_email binding with the config's `emails` envelope:
467
+ through the `EMAIL` send_email binding, from the template's own `from`:
457
468
 
458
469
  ```ts
459
- emails: {
470
+ // src/email/welcome.tsx
471
+ export default emailTemplate({
472
+ id: 'welcome',
460
473
  from: { email: 'noreply@acme.test', name: 'Acme' },
461
- replyTo: { email: 'support@acme.test', name: 'Acme Support' },
462
- unsubscribe: 'https://acme.test/unsubscribe',
463
- tracking: { quickClickMs: 1_000 },
474
+ replyTo: { email: 'support@acme.test', name: 'Acme Support' }, // left out: no Reply-To
475
+ title: 'Welcome',
476
+ props: strictObject({ name: string() }),
477
+ subject: 'Welcome to Acme',
478
+ preview: { name: 'John Doe' },
479
+ component: ({ name }) => <Layout name={name}>…</Layout>,
480
+ });
481
+ ```
482
+
483
+ There is no global sender: share addresses through a module of your own. The
484
+ unsubscribe link is the template's `unsubscribeUrl`, else the config's
485
+ `email.unsubscribeUrl`, else none (`resolveEmail` is the rule). A layout reads
486
+ it from the render, never from a constant, so a template's own URL reaches
487
+ its HTML:
488
+
489
+ ```tsx
490
+ import { useEmailContext } from '@shware/workflow/email-context';
491
+
492
+ const { unsubscribeUrl } = useEmailContext();
493
+ {
494
+ unsubscribeUrl !== undefined && <Link href={unsubscribeUrl}>Unsubscribe</Link>;
495
+ }
496
+ ```
497
+
498
+ ```ts
499
+ // workflow.config.ts
500
+ email: {
501
+ unsubscribeUrl: 'https://acme.test/unsubscribe', // templates without their own
502
+ tracking: { quickClickMs: 1_000 }, // the default
464
503
  },
465
504
  ```
466
505
 
467
506
  An email step refuses to send when the profile has no `email`.
468
- `GET /preview/<key>?props=<JSON>` renders a template, with the subject in the
507
+ `GET /preview/<id>?props=<JSON>` renders a template, with the subject in the
469
508
  `x-subject` header.
470
509
 
471
510
  ### Push
472
511
 
473
- `pushTemplate({ title, description?, props, preview, content })`, where
512
+ `pushTemplate({ id, title, description?, props, preview, content })`, where
474
513
  `content(props)` returns `{ title, body, image?, url? }`: final text, never
475
514
  placeholder-expanded. `url` is the page a browser push opens.
476
515
 
477
516
  ```ts
478
517
  export default pushTemplate({
518
+ id: 'onboarding_tip_push',
479
519
  title: 'Onboarding tip (push)',
480
520
  props: strictObject({ name: string() }),
481
521
  preview: { name: 'John Doe' },
@@ -528,6 +568,7 @@ import { optional, strictObject, string } from 'zod/mini';
528
568
  import type { Env } from '../env';
529
569
 
530
570
  export default slackTemplate({
571
+ id: 'nps_detractor',
531
572
  title: 'NPS detractor (support)',
532
573
  props: strictObject({ name: string(), email: optional(string()) }),
533
574
  preview: { name: 'John Doe', email: 'john@example.com' },
@@ -544,9 +585,8 @@ post never holds up the messages after it.
544
585
 
545
586
  ### Other channels
546
587
 
547
- `.sms()`, `.inApp()` and `.survey()` compile, and `template.sms<P>(key)` (also
548
- `email`, `push`, `inApp`, `slack`, `discord`, `survey`) makes an unregistered,
549
- hand-typed reference. The config's channels deliver only email, push, Slack and
588
+ `.sms()`, `.inApp()` and `.survey()` compile, and take a hand-typed reference
589
+ by key: `template.sms<P>(key)` (also `inApp`, `survey`). The config's channels deliver only email, push, Slack and
550
590
  Discord; supply anything else with `journeyWorker({ messages })`.
551
591
 
552
592
  ## Configuration
@@ -561,7 +601,7 @@ Discord; supply anything else with `journeyWorker({ messages })`.
561
601
  | `segments` | Named segments |
562
602
  | `actions` | Custom actions `.run()` uses |
563
603
  | `templates` | The registries: `{ email?, push?, discord?, slack? }` |
564
- | `emails` | `{ from, replyTo?, unsubscribe?, tracking? }` |
604
+ | `email` | `{ unsubscribeUrl?, tracking? }`: what every email template shares |
565
605
  | `runtime` | Per mode (`development`, `production`, …): `{ timeScale?, logMessages? }` |
566
606
  | `apns` | `{ topic, keyId, teamId, environment? }` |
567
607
  | `fcm` | `{ androidChannelId? }` |
@@ -651,8 +691,8 @@ workflows actually changed. Deploy recomputes every declared hash and refuses a
651
691
  bundle whose hashes do not describe its contents.
652
692
 
653
693
  Node ids are structural paths, so inserting a node shifts the ids of the nodes
654
- after it. Template keys are stable identities too: a key in flight takes only
655
- backward-compatible changes, so a new required prop means a new key.
694
+ after it. Template ids are stable identities too: an id in flight takes only
695
+ backward-compatible changes, so a new required prop means a new id.
656
696
 
657
697
  ## Cloudflare setup
658
698
 
@@ -665,7 +705,7 @@ The default store needs three bindings, named as in `JourneyEnv`:
665
705
  | `JOURNEY` | Workflow | The `JourneyRunner` entrypoint |
666
706
 
667
707
  A Worker on Postgres needs only `JOURNEY`. Add the `EMAIL` send_email binding
668
- when the config has `emails`, and a `triggers.crons` entry with `scheduled`
708
+ when the config registers email templates, and a `triggers.crons` entry with `scheduled`
669
709
  exported to run the segment sweep.
670
710
 
671
711
  Set `API_TOKEN` in production. Without it every mutating endpoint is open, and
@@ -742,7 +782,8 @@ On Postgres the engine also keeps:
742
782
  by the default observer. With `WORKFLOW_ORIGIN` set, email links and an open
743
783
  pixel are tracked through the Worker's `/t/...` routes. Each open and click
744
784
  is classified as a person's or a machine's (Apple Mail Privacy Protection,
745
- link scanners, prefetches). `emails.unsubscribe` gets a confirmation page.
785
+ link scanners, prefetches). An email's unsubscribe link gets a confirmation
786
+ page; which link that is was fixed when the email was sent.
746
787
  A push carries `message_id`; report the tap with `messageOpened`.
747
788
  - **Segments.** The cron trigger runs `sweepSegments`, which keeps every
748
789
  segment's membership and size and starts the journeys of segments entered by
package/dist/client.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { Contract, ContractIssue, ProfilePatch } from "./contract.cjs";
2
1
  import { ProfileProps } from "./store/index.cjs";
2
+ import { Contract, ContractIssue, ProfilePatch } from "./contract.cjs";
3
3
  import { DeployResult, IdentifyResult, IngestResult } from "./cloudflare/router.cjs";
4
4
  import "./cloudflare/index.cjs";
5
5
  import * as core from "zod/v4/core";
package/dist/client.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { Contract, ContractIssue, ProfilePatch } from "./contract.mjs";
2
1
  import { ProfileProps } from "./store/index.mjs";
2
+ import { Contract, ContractIssue, ProfilePatch } from "./contract.mjs";
3
3
  import { DeployResult, IdentifyResult, IngestResult } from "./cloudflare/router.mjs";
4
4
  import "./cloudflare/index.mjs";
5
5
  import * as core from "zod/v4/core";
@@ -1 +1 @@
1
- {"version":3,"file":"bindings.cjs","names":[],"sources":["../../src/cloudflare/bindings.ts"],"sourcesContent":["import type { EmailAddress } from '../config';\n\n/**\n * A minimal structural interface over the Cloudflare bindings.\n *\n * @cloudflare/workers-types is deliberately not used: the global types it\n * injects clash with the DOM lib this package's react side needs. A structural\n * subset plus the ambient cloudflare:workers declaration is enough to compile,\n * and the real bindings satisfy it structurally at runtime.\n */\n\nexport interface KVNamespaceLike {\n get(key: string): Promise<string | null>;\n put(key: string, value: string): Promise<void>;\n}\n\n/** Structural subset of D1Result: `meta.changes` is how INSERT OR IGNORE reports whether it inserted. */\nexport interface D1RunResultLike {\n meta?: { changes?: number };\n}\n\nexport interface D1PreparedStatementLike {\n bind(...values: unknown[]): D1PreparedStatementLike;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n run(): Promise<D1RunResultLike>;\n}\n\nexport interface D1DatabaseLike {\n prepare(sql: string): D1PreparedStatementLike;\n /** Statements run in order inside one transaction (real D1 semantics) — deploy relies on the atomicity. */\n batch(statements: D1PreparedStatementLike[]): Promise<unknown>;\n}\n\nexport interface WorkflowInstanceLike {\n id: string;\n sendEvent(event: { type: string; payload?: unknown }): Promise<void>;\n /** Stop the instance for good (Cloudflare Workflows' `terminate`). */\n terminate?(): Promise<void>;\n /** Where it stands (`complete`, `errored`, `terminated`, `running`, …). */\n status?(): Promise<{ status: string }>;\n}\n\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n get(id: string): Promise<WorkflowInstanceLike>;\n}\n\n/**\n * Cloudflare Email Service's send_email binding (structural subset).\n *\n * `idempotencyKey` is part of the call because the send happens inside a\n * `step.do`: a step body re-runs after its fn resolved but before the\n * checkpoint committed, so a binding that drops the key mails the user twice.\n * The binding is app-supplied glue (nothing here can de-duplicate — there is\n * no state between two runs of the same step), which is why the port makes the\n * key impossible to miss rather than merely available.\n */\nexport interface EmailBindingLike {\n send(message: {\n from: EmailAddress;\n to: EmailAddress;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */\n idempotencyKey: string;\n }): Promise<unknown>;\n}\n\n/** Every binding the journey engine needs (names match the wrangler config). */\nexport interface JourneyEnv {\n /**\n * BundleIR storage for the default D1 store (`wf:${contentHash}` → WorkflowIR JSON). Optional:\n * a Worker on another JourneyStore (Postgres, say) does not declare it.\n */\n WORKFLOW_KV?: KVNamespaceLike;\n /** Tables of the default D1 store (events / profiles / segments / triggers / entries / subscriptions). Optional, as above. */\n DB?: D1DatabaseLike;\n /** JourneyRunner's workflow binding (creating instances and waking them). */\n JOURNEY: WorkflowBindingLike;\n /** Optional message-delivery webhook (defaults to the console logging sender). */\n MESSAGE_WEBHOOK_URL?: string;\n /**\n * Bearer token required on every mutating HTTP endpoint (`Authorization:\n * Bearer <token>`). Unset = open — acceptable only for local dev; /deploy in\n * particular rewrites the whole routing table, so production must set this.\n */\n API_TOKEN?: string;\n /**\n * This Worker's public origin. With a store that keeps reporting (Postgres), journey emails'\n * links and open pixel point at its `/t/...` routes; without it emails go out untracked.\n */\n WORKFLOW_ORIGIN?: string;\n /** Which `workflow.config.ts` entry applies (`development`, `production`, …); production when unset. */\n ENVIRONMENT?: string;\n /** Cloudflare Email Sending, when the config has `emails`. */\n EMAIL?: EmailBindingLike;\n /** The APNs auth key (`.p8`, base64) named by the config's `apns.keyId`. */\n APNS_PRIVATE_KEY_BASE64?: string;\n /** The Firebase service account (base64 JSON) the config's `fcm` sends with. */\n GOOGLE_APPLICATION_CREDENTIALS_BASE64?: string;\n /** The VAPID key pair web pushes are signed with (`generateVapidKeys`); the config's `webPush` turns the channel on. */\n VAPID_PUBLIC_KEY?: string;\n VAPID_PRIVATE_KEY?: string;\n /**\n * Cloudflare's version metadata binding (wrangler `\"version_metadata\": { \"binding\":\n * \"CF_VERSION_METADATA\" }`). With it, and a store that records the live version (Postgres),\n * journeyWorker makes the bundle compiled into each new version live by itself: `wrangler\n * deploy` is the whole deploy.\n */\n CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string };\n /** Development only: the HTTP/2 relay `wrangler dev` sends APNs requests through (see `@shware/workflow/apns-relay`). */\n APNS_RELAY_ORIGIN?: string;\n}\n\n/** The event type the router uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the ingest router when it creates one. */\nexport interface JourneyParams {\n workflowId: string;\n /** The IR version pinned at entry. */\n contentHash: string;\n userId: string;\n trigger: { event: string; payload: Record<string, unknown> };\n [key: string]: unknown;\n}\n\n/**\n * Slack on the deadline when deciding whether a thrown wait was a timeout: the\n * platform may surface the expiry a moment early, and a wait shorter than this\n * has no room to tell the two apart anyway.\n */\nexport const WAKE_TIMEOUT_TOLERANCE_MS = 5_000;\n\n/**\n * Did a wait that threw actually reach its deadline?\n *\n * CF signals a wait timeout by throwing, and so does every other failure in\n * there — a broken binding, an evicted instance, a malformed timeout. The\n * adapter used to read them all as timeouts, which made an infrastructure\n * fault indistinguishable from \"the user never did it\": the journey took its\n * onTimeout branch, early and silently. Only the clock can tell them apart.\n */\nexport function wakeExpired(startedAt: number, timeoutMs: number, now: number): boolean {\n return now - startedAt >= Math.max(0, timeoutMs - WAKE_TIMEOUT_TOLERANCE_MS);\n}\n"],"mappings":";;;AAqHA,MAAa,kBAAkB;;;;;;AAiB/B,MAAa,4BAA4B;;;;;;;;;;AAWzC,SAAgB,YAAY,WAAmB,WAAmB,KAAsB;CACtF,OAAO,MAAM,aAAa,KAAK,IAAI,GAAG,YAAY,yBAAyB;AAC7E"}
1
+ {"version":3,"file":"bindings.cjs","names":[],"sources":["../../src/cloudflare/bindings.ts"],"sourcesContent":["import type { EmailAddress } from '../email';\n\n/**\n * A minimal structural interface over the Cloudflare bindings.\n *\n * @cloudflare/workers-types is deliberately not used: the global types it\n * injects clash with the DOM lib this package's react side needs. A structural\n * subset plus the ambient cloudflare:workers declaration is enough to compile,\n * and the real bindings satisfy it structurally at runtime.\n */\n\nexport interface KVNamespaceLike {\n get(key: string): Promise<string | null>;\n put(key: string, value: string): Promise<void>;\n}\n\n/** Structural subset of D1Result: `meta.changes` is how INSERT OR IGNORE reports whether it inserted. */\nexport interface D1RunResultLike {\n meta?: { changes?: number };\n}\n\nexport interface D1PreparedStatementLike {\n bind(...values: unknown[]): D1PreparedStatementLike;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n run(): Promise<D1RunResultLike>;\n}\n\nexport interface D1DatabaseLike {\n prepare(sql: string): D1PreparedStatementLike;\n /** Statements run in order inside one transaction (real D1 semantics) — deploy relies on the atomicity. */\n batch(statements: D1PreparedStatementLike[]): Promise<unknown>;\n}\n\nexport interface WorkflowInstanceLike {\n id: string;\n sendEvent(event: { type: string; payload?: unknown }): Promise<void>;\n /** Stop the instance for good (Cloudflare Workflows' `terminate`). */\n terminate?(): Promise<void>;\n /** Where it stands (`complete`, `errored`, `terminated`, `running`, …). */\n status?(): Promise<{ status: string }>;\n}\n\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n get(id: string): Promise<WorkflowInstanceLike>;\n}\n\n/**\n * Cloudflare Email Service's send_email binding (structural subset).\n *\n * `idempotencyKey` is part of the call because the send happens inside a\n * `step.do`: a step body re-runs after its fn resolved but before the\n * checkpoint committed, so a binding that drops the key mails the user twice.\n * The binding is app-supplied glue (nothing here can de-duplicate — there is\n * no state between two runs of the same step), which is why the port makes the\n * key impossible to miss rather than merely available.\n */\nexport interface EmailBindingLike {\n send(message: {\n from: EmailAddress;\n to: EmailAddress;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */\n idempotencyKey: string;\n }): Promise<unknown>;\n}\n\n/** Every binding the journey engine needs (names match the wrangler config). */\nexport interface JourneyEnv {\n /**\n * BundleIR storage for the default D1 store (`wf:${contentHash}` → WorkflowIR JSON). Optional:\n * a Worker on another JourneyStore (Postgres, say) does not declare it.\n */\n WORKFLOW_KV?: KVNamespaceLike;\n /** Tables of the default D1 store (events / profiles / segments / triggers / entries / subscriptions). Optional, as above. */\n DB?: D1DatabaseLike;\n /** JourneyRunner's workflow binding (creating instances and waking them). */\n JOURNEY: WorkflowBindingLike;\n /** Optional message-delivery webhook (defaults to the console logging sender). */\n MESSAGE_WEBHOOK_URL?: string;\n /**\n * Bearer token required on every mutating HTTP endpoint (`Authorization:\n * Bearer <token>`). Unset = open — acceptable only for local dev; /deploy in\n * particular rewrites the whole routing table, so production must set this.\n */\n API_TOKEN?: string;\n /**\n * This Worker's public origin. With a store that keeps reporting (Postgres), journey emails'\n * links and open pixel point at its `/t/...` routes; without it emails go out untracked.\n */\n WORKFLOW_ORIGIN?: string;\n /** Which `workflow.config.ts` entry applies (`development`, `production`, …); production when unset. */\n ENVIRONMENT?: string;\n /** Cloudflare Email Sending, when the config has `emails`. */\n EMAIL?: EmailBindingLike;\n /** The APNs auth key (`.p8`, base64) named by the config's `apns.keyId`. */\n APNS_PRIVATE_KEY_BASE64?: string;\n /** The Firebase service account (base64 JSON) the config's `fcm` sends with. */\n GOOGLE_APPLICATION_CREDENTIALS_BASE64?: string;\n /** The VAPID key pair web pushes are signed with (`generateVapidKeys`); the config's `webPush` turns the channel on. */\n VAPID_PUBLIC_KEY?: string;\n VAPID_PRIVATE_KEY?: string;\n /**\n * Cloudflare's version metadata binding (wrangler `\"version_metadata\": { \"binding\":\n * \"CF_VERSION_METADATA\" }`). With it, and a store that records the live version (Postgres),\n * journeyWorker makes the bundle compiled into each new version live by itself: `wrangler\n * deploy` is the whole deploy.\n */\n CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string };\n /** Development only: the HTTP/2 relay `wrangler dev` sends APNs requests through (see `@shware/workflow/apns-relay`). */\n APNS_RELAY_ORIGIN?: string;\n}\n\n/** The event type the router uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the ingest router when it creates one. */\nexport interface JourneyParams {\n workflowId: string;\n /** The IR version pinned at entry. */\n contentHash: string;\n userId: string;\n trigger: { event: string; payload: Record<string, unknown> };\n [key: string]: unknown;\n}\n\n/**\n * Slack on the deadline when deciding whether a thrown wait was a timeout: the\n * platform may surface the expiry a moment early, and a wait shorter than this\n * has no room to tell the two apart anyway.\n */\nexport const WAKE_TIMEOUT_TOLERANCE_MS = 5_000;\n\n/**\n * Did a wait that threw actually reach its deadline?\n *\n * CF signals a wait timeout by throwing, and so does every other failure in\n * there — a broken binding, an evicted instance, a malformed timeout. The\n * adapter used to read them all as timeouts, which made an infrastructure\n * fault indistinguishable from \"the user never did it\": the journey took its\n * onTimeout branch, early and silently. Only the clock can tell them apart.\n */\nexport function wakeExpired(startedAt: number, timeoutMs: number, now: number): boolean {\n return now - startedAt >= Math.max(0, timeoutMs - WAKE_TIMEOUT_TOLERANCE_MS);\n}\n"],"mappings":";;;AAqHA,MAAa,kBAAkB;;;;;;AAiB/B,MAAa,4BAA4B;;;;;;;;;;AAWzC,SAAgB,YAAY,WAAmB,WAAmB,KAAsB;CACtF,OAAO,MAAM,aAAa,KAAK,IAAI,GAAG,YAAY,yBAAyB;AAC7E"}
@@ -1,4 +1,4 @@
1
- import { EmailAddress } from "../config.cjs";
1
+ import { EmailAddress } from "../email.cjs";
2
2
  //#region src/cloudflare/bindings.d.ts
3
3
  /**
4
4
  * A minimal structural interface over the Cloudflare bindings.
@@ -1,4 +1,4 @@
1
- import { EmailAddress } from "../config.mjs";
1
+ import { EmailAddress } from "../email.mjs";
2
2
  //#region src/cloudflare/bindings.d.ts
3
3
  /**
4
4
  * A minimal structural interface over the Cloudflare bindings.
@@ -1 +1 @@
1
- {"version":3,"file":"bindings.mjs","names":[],"sources":["../../src/cloudflare/bindings.ts"],"sourcesContent":["import type { EmailAddress } from '../config';\n\n/**\n * A minimal structural interface over the Cloudflare bindings.\n *\n * @cloudflare/workers-types is deliberately not used: the global types it\n * injects clash with the DOM lib this package's react side needs. A structural\n * subset plus the ambient cloudflare:workers declaration is enough to compile,\n * and the real bindings satisfy it structurally at runtime.\n */\n\nexport interface KVNamespaceLike {\n get(key: string): Promise<string | null>;\n put(key: string, value: string): Promise<void>;\n}\n\n/** Structural subset of D1Result: `meta.changes` is how INSERT OR IGNORE reports whether it inserted. */\nexport interface D1RunResultLike {\n meta?: { changes?: number };\n}\n\nexport interface D1PreparedStatementLike {\n bind(...values: unknown[]): D1PreparedStatementLike;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n run(): Promise<D1RunResultLike>;\n}\n\nexport interface D1DatabaseLike {\n prepare(sql: string): D1PreparedStatementLike;\n /** Statements run in order inside one transaction (real D1 semantics) — deploy relies on the atomicity. */\n batch(statements: D1PreparedStatementLike[]): Promise<unknown>;\n}\n\nexport interface WorkflowInstanceLike {\n id: string;\n sendEvent(event: { type: string; payload?: unknown }): Promise<void>;\n /** Stop the instance for good (Cloudflare Workflows' `terminate`). */\n terminate?(): Promise<void>;\n /** Where it stands (`complete`, `errored`, `terminated`, `running`, …). */\n status?(): Promise<{ status: string }>;\n}\n\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n get(id: string): Promise<WorkflowInstanceLike>;\n}\n\n/**\n * Cloudflare Email Service's send_email binding (structural subset).\n *\n * `idempotencyKey` is part of the call because the send happens inside a\n * `step.do`: a step body re-runs after its fn resolved but before the\n * checkpoint committed, so a binding that drops the key mails the user twice.\n * The binding is app-supplied glue (nothing here can de-duplicate — there is\n * no state between two runs of the same step), which is why the port makes the\n * key impossible to miss rather than merely available.\n */\nexport interface EmailBindingLike {\n send(message: {\n from: EmailAddress;\n to: EmailAddress;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */\n idempotencyKey: string;\n }): Promise<unknown>;\n}\n\n/** Every binding the journey engine needs (names match the wrangler config). */\nexport interface JourneyEnv {\n /**\n * BundleIR storage for the default D1 store (`wf:${contentHash}` → WorkflowIR JSON). Optional:\n * a Worker on another JourneyStore (Postgres, say) does not declare it.\n */\n WORKFLOW_KV?: KVNamespaceLike;\n /** Tables of the default D1 store (events / profiles / segments / triggers / entries / subscriptions). Optional, as above. */\n DB?: D1DatabaseLike;\n /** JourneyRunner's workflow binding (creating instances and waking them). */\n JOURNEY: WorkflowBindingLike;\n /** Optional message-delivery webhook (defaults to the console logging sender). */\n MESSAGE_WEBHOOK_URL?: string;\n /**\n * Bearer token required on every mutating HTTP endpoint (`Authorization:\n * Bearer <token>`). Unset = open — acceptable only for local dev; /deploy in\n * particular rewrites the whole routing table, so production must set this.\n */\n API_TOKEN?: string;\n /**\n * This Worker's public origin. With a store that keeps reporting (Postgres), journey emails'\n * links and open pixel point at its `/t/...` routes; without it emails go out untracked.\n */\n WORKFLOW_ORIGIN?: string;\n /** Which `workflow.config.ts` entry applies (`development`, `production`, …); production when unset. */\n ENVIRONMENT?: string;\n /** Cloudflare Email Sending, when the config has `emails`. */\n EMAIL?: EmailBindingLike;\n /** The APNs auth key (`.p8`, base64) named by the config's `apns.keyId`. */\n APNS_PRIVATE_KEY_BASE64?: string;\n /** The Firebase service account (base64 JSON) the config's `fcm` sends with. */\n GOOGLE_APPLICATION_CREDENTIALS_BASE64?: string;\n /** The VAPID key pair web pushes are signed with (`generateVapidKeys`); the config's `webPush` turns the channel on. */\n VAPID_PUBLIC_KEY?: string;\n VAPID_PRIVATE_KEY?: string;\n /**\n * Cloudflare's version metadata binding (wrangler `\"version_metadata\": { \"binding\":\n * \"CF_VERSION_METADATA\" }`). With it, and a store that records the live version (Postgres),\n * journeyWorker makes the bundle compiled into each new version live by itself: `wrangler\n * deploy` is the whole deploy.\n */\n CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string };\n /** Development only: the HTTP/2 relay `wrangler dev` sends APNs requests through (see `@shware/workflow/apns-relay`). */\n APNS_RELAY_ORIGIN?: string;\n}\n\n/** The event type the router uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the ingest router when it creates one. */\nexport interface JourneyParams {\n workflowId: string;\n /** The IR version pinned at entry. */\n contentHash: string;\n userId: string;\n trigger: { event: string; payload: Record<string, unknown> };\n [key: string]: unknown;\n}\n\n/**\n * Slack on the deadline when deciding whether a thrown wait was a timeout: the\n * platform may surface the expiry a moment early, and a wait shorter than this\n * has no room to tell the two apart anyway.\n */\nexport const WAKE_TIMEOUT_TOLERANCE_MS = 5_000;\n\n/**\n * Did a wait that threw actually reach its deadline?\n *\n * CF signals a wait timeout by throwing, and so does every other failure in\n * there — a broken binding, an evicted instance, a malformed timeout. The\n * adapter used to read them all as timeouts, which made an infrastructure\n * fault indistinguishable from \"the user never did it\": the journey took its\n * onTimeout branch, early and silently. Only the clock can tell them apart.\n */\nexport function wakeExpired(startedAt: number, timeoutMs: number, now: number): boolean {\n return now - startedAt >= Math.max(0, timeoutMs - WAKE_TIMEOUT_TOLERANCE_MS);\n}\n"],"mappings":";;AAqHA,MAAa,kBAAkB;;;;;;AAiB/B,MAAa,4BAA4B;;;;;;;;;;AAWzC,SAAgB,YAAY,WAAmB,WAAmB,KAAsB;CACtF,OAAO,MAAM,aAAa,KAAK,IAAI,GAAG,YAAY,yBAAyB;AAC7E"}
1
+ {"version":3,"file":"bindings.mjs","names":[],"sources":["../../src/cloudflare/bindings.ts"],"sourcesContent":["import type { EmailAddress } from '../email';\n\n/**\n * A minimal structural interface over the Cloudflare bindings.\n *\n * @cloudflare/workers-types is deliberately not used: the global types it\n * injects clash with the DOM lib this package's react side needs. A structural\n * subset plus the ambient cloudflare:workers declaration is enough to compile,\n * and the real bindings satisfy it structurally at runtime.\n */\n\nexport interface KVNamespaceLike {\n get(key: string): Promise<string | null>;\n put(key: string, value: string): Promise<void>;\n}\n\n/** Structural subset of D1Result: `meta.changes` is how INSERT OR IGNORE reports whether it inserted. */\nexport interface D1RunResultLike {\n meta?: { changes?: number };\n}\n\nexport interface D1PreparedStatementLike {\n bind(...values: unknown[]): D1PreparedStatementLike;\n first<T = Record<string, unknown>>(): Promise<T | null>;\n all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;\n run(): Promise<D1RunResultLike>;\n}\n\nexport interface D1DatabaseLike {\n prepare(sql: string): D1PreparedStatementLike;\n /** Statements run in order inside one transaction (real D1 semantics) — deploy relies on the atomicity. */\n batch(statements: D1PreparedStatementLike[]): Promise<unknown>;\n}\n\nexport interface WorkflowInstanceLike {\n id: string;\n sendEvent(event: { type: string; payload?: unknown }): Promise<void>;\n /** Stop the instance for good (Cloudflare Workflows' `terminate`). */\n terminate?(): Promise<void>;\n /** Where it stands (`complete`, `errored`, `terminated`, `running`, …). */\n status?(): Promise<{ status: string }>;\n}\n\nexport interface WorkflowBindingLike {\n create(options: { id: string; params: unknown }): Promise<WorkflowInstanceLike>;\n get(id: string): Promise<WorkflowInstanceLike>;\n}\n\n/**\n * Cloudflare Email Service's send_email binding (structural subset).\n *\n * `idempotencyKey` is part of the call because the send happens inside a\n * `step.do`: a step body re-runs after its fn resolved but before the\n * checkpoint committed, so a binding that drops the key mails the user twice.\n * The binding is app-supplied glue (nothing here can de-duplicate — there is\n * no state between two runs of the same step), which is why the port makes the\n * key impossible to miss rather than merely available.\n */\nexport interface EmailBindingLike {\n send(message: {\n from: EmailAddress;\n to: EmailAddress;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /** `${instanceId}:${nodeId}`: stable across replays and retries — drop a send whose key was already delivered. */\n idempotencyKey: string;\n }): Promise<unknown>;\n}\n\n/** Every binding the journey engine needs (names match the wrangler config). */\nexport interface JourneyEnv {\n /**\n * BundleIR storage for the default D1 store (`wf:${contentHash}` → WorkflowIR JSON). Optional:\n * a Worker on another JourneyStore (Postgres, say) does not declare it.\n */\n WORKFLOW_KV?: KVNamespaceLike;\n /** Tables of the default D1 store (events / profiles / segments / triggers / entries / subscriptions). Optional, as above. */\n DB?: D1DatabaseLike;\n /** JourneyRunner's workflow binding (creating instances and waking them). */\n JOURNEY: WorkflowBindingLike;\n /** Optional message-delivery webhook (defaults to the console logging sender). */\n MESSAGE_WEBHOOK_URL?: string;\n /**\n * Bearer token required on every mutating HTTP endpoint (`Authorization:\n * Bearer <token>`). Unset = open — acceptable only for local dev; /deploy in\n * particular rewrites the whole routing table, so production must set this.\n */\n API_TOKEN?: string;\n /**\n * This Worker's public origin. With a store that keeps reporting (Postgres), journey emails'\n * links and open pixel point at its `/t/...` routes; without it emails go out untracked.\n */\n WORKFLOW_ORIGIN?: string;\n /** Which `workflow.config.ts` entry applies (`development`, `production`, …); production when unset. */\n ENVIRONMENT?: string;\n /** Cloudflare Email Sending, when the config has `emails`. */\n EMAIL?: EmailBindingLike;\n /** The APNs auth key (`.p8`, base64) named by the config's `apns.keyId`. */\n APNS_PRIVATE_KEY_BASE64?: string;\n /** The Firebase service account (base64 JSON) the config's `fcm` sends with. */\n GOOGLE_APPLICATION_CREDENTIALS_BASE64?: string;\n /** The VAPID key pair web pushes are signed with (`generateVapidKeys`); the config's `webPush` turns the channel on. */\n VAPID_PUBLIC_KEY?: string;\n VAPID_PRIVATE_KEY?: string;\n /**\n * Cloudflare's version metadata binding (wrangler `\"version_metadata\": { \"binding\":\n * \"CF_VERSION_METADATA\" }`). With it, and a store that records the live version (Postgres),\n * journeyWorker makes the bundle compiled into each new version live by itself: `wrangler\n * deploy` is the whole deploy.\n */\n CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string };\n /** Development only: the HTTP/2 relay `wrangler dev` sends APNs requests through (see `@shware/workflow/apns-relay`). */\n APNS_RELAY_ORIGIN?: string;\n}\n\n/** The event type the router uses to wake waiting instances (sendEvent's `type`). */\nexport const WAKE_EVENT_TYPE = 'wake';\n\n/** Journey instance parameters, passed by the ingest router when it creates one. */\nexport interface JourneyParams {\n workflowId: string;\n /** The IR version pinned at entry. */\n contentHash: string;\n userId: string;\n trigger: { event: string; payload: Record<string, unknown> };\n [key: string]: unknown;\n}\n\n/**\n * Slack on the deadline when deciding whether a thrown wait was a timeout: the\n * platform may surface the expiry a moment early, and a wait shorter than this\n * has no room to tell the two apart anyway.\n */\nexport const WAKE_TIMEOUT_TOLERANCE_MS = 5_000;\n\n/**\n * Did a wait that threw actually reach its deadline?\n *\n * CF signals a wait timeout by throwing, and so does every other failure in\n * there — a broken binding, an evicted instance, a malformed timeout. The\n * adapter used to read them all as timeouts, which made an infrastructure\n * fault indistinguishable from \"the user never did it\": the journey took its\n * onTimeout branch, early and silently. Only the clock can tell them apart.\n */\nexport function wakeExpired(startedAt: number, timeoutMs: number, now: number): boolean {\n return now - startedAt >= Math.max(0, timeoutMs - WAKE_TIMEOUT_TOLERANCE_MS);\n}\n"],"mappings":";;AAqHA,MAAa,kBAAkB;;;;;;AAiB/B,MAAa,4BAA4B;;;;;;;;;;AAWzC,SAAgB,YAAY,WAAmB,WAAmB,KAAsB;CACtF,OAAO,MAAM,aAAa,KAAK,IAAI,GAAG,YAAY,yBAAyB;AAC7E"}
@@ -58,12 +58,9 @@ function channelsFromConfig(config, env, options = {}) {
58
58
  const registry = config.templates?.email;
59
59
  if (registry === void 0) return unavailable("no email templates are registered");
60
60
  if (env.EMAIL === void 0) return unavailable("no EMAIL (send_email) binding");
61
- if (config.emails === void 0) return unavailable("emails.from is not configured");
62
61
  return new require_cloudflare_senders.CfEmailSender({
63
62
  binding: env.EMAIL,
64
- from: config.emails.from,
65
- ...config.emails.replyTo === void 0 ? {} : { replyTo: config.emails.replyTo },
66
- render: require_react_email.registryRenderer(registry)
63
+ render: require_react_email.registryRenderer(registry, config.email)
67
64
  });
68
65
  };
69
66
  const push = () => {
@@ -112,24 +109,21 @@ function channelsFromConfig(config, env, options = {}) {
112
109
  * A webhook template's URL, read from the environment the Worker validated at startup (its
113
110
  * `env` schema): a template whose variable is unset fails the step, naming the template.
114
111
  */
115
- const webhookUrl = (channel, key, template) => {
112
+ const webhookUrl = (channel, template) => {
116
113
  const url = template.webhook(env);
117
- if (typeof url !== "string" || url === "") throw new Error(`${channel} template '${key}': its webhook URL is not set in the environment`);
114
+ if (typeof url !== "string" || url === "") throw new Error(`${channel} template '${template.id}': its webhook URL is not set in the environment`);
118
115
  return url;
119
116
  };
120
- const lookupTemplate = (registry, channel, key) => {
121
- if (!Object.hasOwn(registry, key)) throw new Error(`unknown ${channel} template: ${key}`);
122
- return registry[key];
123
- };
124
117
  const discord = () => {
125
118
  const registry = config.templates?.discord;
126
119
  if (registry === void 0) return unavailable("no discord templates are registered");
120
+ const index = require_templates.indexTemplates("discord", registry);
127
121
  return new require_webhooks.DiscordWebhookSender({
128
- render: (key, props) => {
129
- const template = lookupTemplate(registry, "discord", key);
122
+ render: (id, props) => {
123
+ const template = require_templates.lookupTemplate(index, "discord", id);
130
124
  return {
131
- url: webhookUrl("discord", key, template),
132
- content: require_templates.renderDiscordContent(template, key, props)
125
+ url: webhookUrl("discord", template),
126
+ content: require_templates.renderDiscordContent(template, props)
133
127
  };
134
128
  },
135
129
  ...options.fetch === void 0 ? {} : { fetch: options.fetch }
@@ -138,12 +132,13 @@ function channelsFromConfig(config, env, options = {}) {
138
132
  const slack = () => {
139
133
  const registry = config.templates?.slack;
140
134
  if (registry === void 0) return unavailable("no slack templates are registered");
135
+ const index = require_templates.indexTemplates("slack", registry);
141
136
  return new require_webhooks.SlackWebhookSender({
142
- render: (key, props) => {
143
- const template = lookupTemplate(registry, "slack", key);
137
+ render: (id, props) => {
138
+ const template = require_templates.lookupTemplate(index, "slack", id);
144
139
  return {
145
- url: webhookUrl("slack", key, template),
146
- content: require_templates.renderSlackContent(template, key, props)
140
+ url: webhookUrl("slack", template),
141
+ content: require_templates.renderSlackContent(template, props)
147
142
  };
148
143
  },
149
144
  ...options.fetch === void 0 ? {} : { fetch: options.fetch }
@@ -1 +1 @@
1
- {"version":3,"file":"channels.cjs","names":["resolveRuntime","CfEmailSender","registryRenderer","registryPushRenderer","removeDevice","ApnsPushSender","apnsKeyFromBase64","apnsEnvironment","FcmPushSender","serviceAccountFromBase64","WebPushSender","routeByPlatform","DiscordWebhookSender","renderDiscordContent","SlackWebhookSender","renderSlackContent","routeByChannel"],"sources":["../../src/cloudflare/channels.ts"],"sourcesContent":["import { ApnsPushSender, apnsKeyFromBase64 } from '../apns';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport type { MessageSender, OutboundMessage } from '../engine/ports';\nimport { FcmPushSender, serviceAccountFromBase64 } from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport { WebPushSender } from '../webpush';\nimport type { JourneyEnv } from './bindings';\nimport { removeDevice } from './router';\nimport { CfEmailSender, routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the `EMAIL` binding with the configured addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/** A sender that fails every message with the reason the channel cannot deliver. */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new Error(`cannot send '${message.template}' on ${message.channel}: ${reason}`);\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n throw new Error(`${name} is not set: the environment must hold it for the configured channel`);\n }\n return value;\n}\n\n/**\n * A sender built on its first message. A platform's secret is only required\n * when something is actually sent there: a journey that only emails must not\n * fail because the APNs key is missing from a local `.dev.vars`.\n */\nfunction lazy(build: () => MessageSender): MessageSender {\n let sender: MessageSender | undefined;\n return {\n async send(message) {\n sender ??= build();\n return sender.send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'emails' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n fetch?: typeof fetch;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n if (env.EMAIL === undefined) return unavailable('no EMAIL (send_email) binding');\n if (config.emails === undefined) return unavailable('emails.from is not configured');\n return new CfEmailSender({\n binding: env.EMAIL,\n from: config.emails.from,\n ...(config.emails.replyTo === undefined ? {} : { replyTo: config.emails.replyTo }),\n render: registryRenderer(registry),\n });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n // A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n // waits that read devices, as a removal through the API does\n const onUnregistered = async (message: OutboundMessage) => {\n if (message.device !== undefined && options.store !== undefined) {\n await removeDevice(env, message.device.token, options.store);\n }\n };\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n platforms.ios = lazy(\n () =>\n new ApnsPushSender({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(requireSecret(env, 'APNS_PRIVATE_KEY_BASE64')),\n topic: apns.topic,\n environment: apnsEnvironment(apns, mode),\n render,\n onUnregistered,\n ...(env.APNS_RELAY_ORIGIN === undefined ? {} : { origin: env.APNS_RELAY_ORIGIN }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = lazy(\n () =>\n new FcmPushSender({\n serviceAccount: serviceAccountFromBase64(\n requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64')\n ),\n render,\n onUnregistered,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = lazy(\n () =>\n new WebPushSender({\n vapid: {\n subject: webPush.subject,\n publicKey: requireSecret(env, 'VAPID_PUBLIC_KEY'),\n privateKey: requireSecret(env, 'VAPID_PRIVATE_KEY'),\n },\n render,\n onUnregistered,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (\n channel: 'discord' | 'slack',\n key: string,\n template: DiscordTemplate | SlackTemplate\n ) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new Error(\n `${channel} template '${key}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n const lookupTemplate = <T>(\n registry: Readonly<Record<string, T>>,\n channel: string,\n key: string\n ) => {\n if (!Object.hasOwn(registry, key)) throw new Error(`unknown ${channel} template: ${key}`);\n return registry[key];\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n return new DiscordWebhookSender({\n render: (key, props) => {\n const template = lookupTemplate(registry, 'discord', key) as DiscordTemplate;\n return {\n url: webhookUrl('discord', key, template),\n content: renderDiscordContent(template, key, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n return new SlackWebhookSender({\n render: (key, props) => {\n const template = lookupTemplate(registry, 'slack', key) as SlackTemplate;\n return {\n url: webhookUrl('slack', key, template),\n content: renderSlackContent(template, key, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"channels.cjs","names":["resolveRuntime","CfEmailSender","registryRenderer","registryPushRenderer","removeDevice","ApnsPushSender","apnsKeyFromBase64","apnsEnvironment","FcmPushSender","serviceAccountFromBase64","WebPushSender","routeByPlatform","indexTemplates","DiscordWebhookSender","lookupTemplate","renderDiscordContent","SlackWebhookSender","renderSlackContent","routeByChannel"],"sources":["../../src/cloudflare/channels.ts"],"sourcesContent":["import { ApnsPushSender, apnsKeyFromBase64 } from '../apns';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport type { MessageSender, OutboundMessage } from '../engine/ports';\nimport { FcmPushSender, serviceAccountFromBase64 } from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n indexTemplates,\n lookupTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport { WebPushSender } from '../webpush';\nimport type { JourneyEnv } from './bindings';\nimport { removeDevice } from './router';\nimport { CfEmailSender, routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the `EMAIL` binding with the configured addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/** A sender that fails every message with the reason the channel cannot deliver. */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new Error(`cannot send '${message.template}' on ${message.channel}: ${reason}`);\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n throw new Error(`${name} is not set: the environment must hold it for the configured channel`);\n }\n return value;\n}\n\n/**\n * A sender built on its first message. A platform's secret is only required\n * when something is actually sent there: a journey that only emails must not\n * fail because the APNs key is missing from a local `.dev.vars`.\n */\nfunction lazy(build: () => MessageSender): MessageSender {\n let sender: MessageSender | undefined;\n return {\n async send(message) {\n sender ??= build();\n return sender.send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n fetch?: typeof fetch;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n if (env.EMAIL === undefined) return unavailable('no EMAIL (send_email) binding');\n // Each template names its own sender: there is no global one to require\n return new CfEmailSender({\n binding: env.EMAIL,\n render: registryRenderer(registry, config.email),\n });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n // A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n // waits that read devices, as a removal through the API does\n const onUnregistered = async (message: OutboundMessage) => {\n if (message.device !== undefined && options.store !== undefined) {\n await removeDevice(env, message.device.token, options.store);\n }\n };\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n platforms.ios = lazy(\n () =>\n new ApnsPushSender({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(requireSecret(env, 'APNS_PRIVATE_KEY_BASE64')),\n topic: apns.topic,\n environment: apnsEnvironment(apns, mode),\n render,\n onUnregistered,\n ...(env.APNS_RELAY_ORIGIN === undefined ? {} : { origin: env.APNS_RELAY_ORIGIN }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = lazy(\n () =>\n new FcmPushSender({\n serviceAccount: serviceAccountFromBase64(\n requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64')\n ),\n render,\n onUnregistered,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = lazy(\n () =>\n new WebPushSender({\n vapid: {\n subject: webPush.subject,\n publicKey: requireSecret(env, 'VAPID_PUBLIC_KEY'),\n privateKey: requireSecret(env, 'VAPID_PRIVATE_KEY'),\n },\n render,\n onUnregistered,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (channel: 'discord' | 'slack', template: DiscordTemplate | SlackTemplate) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new Error(\n `${channel} template '${template.id}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n const index = indexTemplates('discord', registry);\n return new DiscordWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'discord', id);\n return {\n url: webhookUrl('discord', template),\n content: renderDiscordContent(template, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n const index = indexTemplates('slack', registry);\n return new SlackWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'slack', id);\n return { url: webhookUrl('slack', template), content: renderSlackContent(template, props) };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}
@@ -1,8 +1,8 @@
1
1
  import { MessageSender } from "../engine/ports.cjs";
2
- import { JourneyMode, JourneyRuntimeOptions } from "../runtime.cjs";
3
- import { JourneyConfig } from "../config.cjs";
4
2
  import { JourneyEnv } from "./bindings.cjs";
5
3
  import { JourneyStore } from "../store/index.cjs";
4
+ import { JourneyMode, JourneyRuntimeOptions } from "../runtime.cjs";
5
+ import { JourneyConfig } from "../config.cjs";
6
6
  //#region src/cloudflare/channels.d.ts
7
7
  /**
8
8
  * The outlets a `JourneyConfig` describes, assembled against the environment:
@@ -19,7 +19,7 @@ export declare function journeyMode(env: JourneyEnv): JourneyMode;
19
19
  /** The runtime options for this environment's mode. */
20
20
  export declare function runtimeFromConfig(config: Pick<JourneyConfig, 'runtime'> | undefined, env: JourneyEnv): Required<JourneyRuntimeOptions>;
21
21
  /** The channels slice of the config — what `channelsFromConfig` reads. */
22
- export type ChannelsConfig = Pick<JourneyConfig, 'templates' | 'emails' | 'apns' | 'fcm' | 'webPush' | 'runtime'>;
22
+ export type ChannelsConfig = Pick<JourneyConfig, 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'>;
23
23
  export declare function channelsFromConfig(config: ChannelsConfig, env: JourneyEnv, options?: {
24
24
  /** The store a token APNs / FCM / a push service reports as gone is removed from. */
25
25
  store?: JourneyStore | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"channels.d.cts","names":[],"sources":["../../src/cloudflare/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;wBAgCgB,YAAY,KAAK,aAAa;;wBAK9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;YA4CA,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;EACR,eAAe;IAEhB"}
1
+ {"version":3,"file":"channels.d.cts","names":[],"sources":["../../src/cloudflare/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;wBAkCgB,YAAY,KAAK,aAAa;;wBAK9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;YA4CA,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;EACR,eAAe;IAEhB"}
@@ -1,8 +1,8 @@
1
1
  import { MessageSender } from "../engine/ports.mjs";
2
- import { JourneyMode, JourneyRuntimeOptions } from "../runtime.mjs";
3
- import { JourneyConfig } from "../config.mjs";
4
2
  import { JourneyEnv } from "./bindings.mjs";
5
3
  import { JourneyStore } from "../store/index.mjs";
4
+ import { JourneyMode, JourneyRuntimeOptions } from "../runtime.mjs";
5
+ import { JourneyConfig } from "../config.mjs";
6
6
  //#region src/cloudflare/channels.d.ts
7
7
  /**
8
8
  * The outlets a `JourneyConfig` describes, assembled against the environment:
@@ -19,7 +19,7 @@ export declare function journeyMode(env: JourneyEnv): JourneyMode;
19
19
  /** The runtime options for this environment's mode. */
20
20
  export declare function runtimeFromConfig(config: Pick<JourneyConfig, 'runtime'> | undefined, env: JourneyEnv): Required<JourneyRuntimeOptions>;
21
21
  /** The channels slice of the config — what `channelsFromConfig` reads. */
22
- export type ChannelsConfig = Pick<JourneyConfig, 'templates' | 'emails' | 'apns' | 'fcm' | 'webPush' | 'runtime'>;
22
+ export type ChannelsConfig = Pick<JourneyConfig, 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'>;
23
23
  export declare function channelsFromConfig(config: ChannelsConfig, env: JourneyEnv, options?: {
24
24
  /** The store a token APNs / FCM / a push service reports as gone is removed from. */
25
25
  store?: JourneyStore | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"channels.d.mts","names":[],"sources":["../../src/cloudflare/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;wBAgCgB,YAAY,KAAK,aAAa;;wBAK9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;YA4CA,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;EACR,eAAe;IAEhB"}
1
+ {"version":3,"file":"channels.d.mts","names":[],"sources":["../../src/cloudflare/channels.ts"],"mappings":";;;;;;;;;;;;;;;;;wBAkCgB,YAAY,KAAK,aAAa;;wBAK9B,kBACd,QAAQ,KAAK,uCACb,KAAK,aACJ,SAAS;;YA4CA,iBAAiB,KAC3B;wBAIc,mBACd,QAAQ,gBACR,KAAK,YACL;;EAEE,QAAQ;EACR,eAAe;IAEhB"}