@shware/workflow 0.3.0 → 1.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 (100) hide show
  1. package/README.md +48 -38
  2. package/dist/client.d.cts +1 -0
  3. package/dist/client.d.mts +1 -0
  4. package/dist/cloudflare/channels.cjs +12 -14
  5. package/dist/cloudflare/channels.cjs.map +1 -1
  6. package/dist/cloudflare/channels.d.cts.map +1 -1
  7. package/dist/cloudflare/channels.d.mts.map +1 -1
  8. package/dist/cloudflare/channels.mjs +13 -15
  9. package/dist/cloudflare/channels.mjs.map +1 -1
  10. package/dist/config.cjs +61 -36
  11. package/dist/config.cjs.map +1 -1
  12. package/dist/config.d.cts +19 -15
  13. package/dist/config.d.cts.map +1 -1
  14. package/dist/config.d.mts +19 -15
  15. package/dist/config.d.mts.map +1 -1
  16. package/dist/config.mjs +61 -36
  17. package/dist/config.mjs.map +1 -1
  18. package/dist/contract.d.cts +1 -0
  19. package/dist/contract.d.mts +1 -0
  20. package/dist/dsl/action.cjs +1 -1
  21. package/dist/dsl/action.cjs.map +1 -1
  22. package/dist/dsl/action.d.cts +1 -1
  23. package/dist/dsl/action.d.mts +1 -1
  24. package/dist/dsl/action.mjs +1 -1
  25. package/dist/dsl/action.mjs.map +1 -1
  26. package/dist/dsl/bundle.cjs +3 -2
  27. package/dist/dsl/bundle.cjs.map +1 -1
  28. package/dist/dsl/bundle.d.cts +3 -2
  29. package/dist/dsl/bundle.d.cts.map +1 -1
  30. package/dist/dsl/bundle.d.mts +3 -2
  31. package/dist/dsl/bundle.d.mts.map +1 -1
  32. package/dist/dsl/bundle.mjs +3 -2
  33. package/dist/dsl/bundle.mjs.map +1 -1
  34. package/dist/dsl/condition.cjs +1 -1
  35. package/dist/dsl/condition.cjs.map +1 -1
  36. package/dist/dsl/condition.d.cts +2 -1
  37. package/dist/dsl/condition.d.mts +2 -1
  38. package/dist/dsl/condition.mjs +1 -1
  39. package/dist/dsl/condition.mjs.map +1 -1
  40. package/dist/dsl/flow.cjs +45 -17
  41. package/dist/dsl/flow.cjs.map +1 -1
  42. package/dist/dsl/flow.d.cts +31 -17
  43. package/dist/dsl/flow.d.cts.map +1 -1
  44. package/dist/dsl/flow.d.mts +31 -17
  45. package/dist/dsl/flow.d.mts.map +1 -1
  46. package/dist/dsl/flow.mjs +45 -18
  47. package/dist/dsl/flow.mjs.map +1 -1
  48. package/dist/dsl/index.cjs +0 -1
  49. package/dist/dsl/index.d.cts +2 -2
  50. package/dist/dsl/index.d.mts +2 -2
  51. package/dist/dsl/index.mjs +2 -2
  52. package/dist/dsl/template.cjs +2 -47
  53. package/dist/dsl/template.cjs.map +1 -1
  54. package/dist/dsl/template.d.cts +4 -53
  55. package/dist/dsl/template.d.cts.map +1 -1
  56. package/dist/dsl/template.d.mts +4 -53
  57. package/dist/dsl/template.d.mts.map +1 -1
  58. package/dist/dsl/template.mjs +3 -47
  59. package/dist/dsl/template.mjs.map +1 -1
  60. package/dist/dsl/workflow.cjs +1 -1
  61. package/dist/dsl/workflow.cjs.map +1 -1
  62. package/dist/dsl/workflow.d.cts +1 -1
  63. package/dist/dsl/workflow.d.mts +1 -1
  64. package/dist/dsl/workflow.mjs +1 -1
  65. package/dist/dsl/workflow.mjs.map +1 -1
  66. package/dist/index.cjs +5 -2
  67. package/dist/index.d.cts +3 -3
  68. package/dist/index.d.mts +3 -3
  69. package/dist/index.mjs +3 -3
  70. package/dist/ir.d.cts +1 -0
  71. package/dist/ir.d.mts +1 -0
  72. package/dist/provenance.d.cts +1 -0
  73. package/dist/provenance.d.mts +1 -0
  74. package/dist/push.cjs +3 -5
  75. package/dist/push.cjs.map +1 -1
  76. package/dist/push.d.cts +2 -2
  77. package/dist/push.d.cts.map +1 -1
  78. package/dist/push.d.mts +2 -2
  79. package/dist/push.d.mts.map +1 -1
  80. package/dist/push.mjs +4 -6
  81. package/dist/push.mjs.map +1 -1
  82. package/dist/react-email.cjs +5 -5
  83. package/dist/react-email.cjs.map +1 -1
  84. package/dist/react-email.d.cts +1 -1
  85. package/dist/react-email.d.cts.map +1 -1
  86. package/dist/react-email.d.mts +1 -1
  87. package/dist/react-email.d.mts.map +1 -1
  88. package/dist/react-email.mjs +6 -6
  89. package/dist/react-email.mjs.map +1 -1
  90. package/dist/templates.cjs +84 -29
  91. package/dist/templates.cjs.map +1 -1
  92. package/dist/templates.d.cts +50 -11
  93. package/dist/templates.d.cts.map +1 -1
  94. package/dist/templates.d.mts +50 -11
  95. package/dist/templates.d.mts.map +1 -1
  96. package/dist/templates.mjs +81 -30
  97. package/dist/templates.mjs.map +1 -1
  98. package/dist/webpush.d.cts +1 -0
  99. package/dist/webpush.d.mts +1 -0
  100. package/package.json +13 -12
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,7 @@ import { emailTemplate } from '@shware/workflow';
79
81
  import { strictObject, string } from 'zod/mini';
80
82
 
81
83
  export default emailTemplate({
84
+ id: 'welcome',
82
85
  title: 'Welcome',
83
86
  props: strictObject({ name: string() }),
84
87
  subject: 'Welcome aboard',
@@ -91,30 +94,37 @@ export default emailTemplate({
91
94
  });
92
95
  ```
93
96
 
94
- Register templates once, per channel, and derive the references the workflows
95
- use from the same registry:
97
+ Gather them per channel; the workflows send the definitions and the config
98
+ registers the whole:
99
+
100
+ ```ts
101
+ // src/email/index.ts
102
+ import tip from './tip';
103
+ import welcome from './welcome';
104
+
105
+ export const email = { welcome, tip };
106
+ ```
96
107
 
97
108
  ```ts
98
109
  // src/templates.ts
99
- import { templates } from '@shware/workflow';
100
- import welcome from './email/welcome';
101
- import tip from './email/tip';
110
+ import { email } from './email';
102
111
 
103
- export const registry = { email: { welcome, tip } };
104
- export const t = templates(registry);
112
+ export const templates = { email };
105
113
  ```
106
114
 
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.
115
+ `tip.tsx` is written the same way. The property names are JavaScript names
116
+ only. `.email(email.welcome, props)` checks `props` against that template's
117
+ schema, and a push template is not accepted where an email is. The compile
118
+ refuses an id registered twice in a channel, and a workflow sending a
119
+ definition other than the one registered under its id.
110
120
 
111
121
  ### 3. A workflow
112
122
 
113
123
  ```ts
114
124
  // src/workflows/welcome.ts
115
125
  import { not, performed, trigger, workflow } from '@shware/workflow';
116
- import { t } from '../templates';
117
126
  import { e, u } from '../contract';
127
+ import { email } from '../email';
118
128
 
119
129
  export const welcomeSeries = workflow({
120
130
  id: 'welcome_series',
@@ -122,9 +132,9 @@ export const welcomeSeries = workflow({
122
132
  trigger: trigger.event(e.login),
123
133
  goal: { condition: performed(e.purchase), within: '7 days' },
124
134
  })
125
- .email(t.email.welcome, { name: u.name })
135
+ .email(email.welcome, { name: u.name })
126
136
  .delay('2 days')
127
- .branch([not(performed(e.purchase)), (w) => w.email(t.email.tip, { name: u.name })]);
137
+ .branch([not(performed(e.purchase)), (w) => w.email(email.tip, { name: u.name })]);
128
138
  ```
129
139
 
130
140
  `id` is wire identity (instances, the entry ledger and stats are keyed on it),
@@ -136,14 +146,14 @@ so rename `name` instead.
136
146
  // workflow.config.ts
137
147
  import { defineConfig } from '@shware/workflow';
138
148
  import { contract } from './src/contract';
139
- import { registry } from './src/templates';
149
+ import { templates } from './src/templates';
140
150
  import { welcomeSeries } from './src/workflows/welcome';
141
151
 
142
152
  export default defineConfig({
143
153
  title: 'Workflow Studio · Acme',
144
154
  contract,
145
155
  workflows: [welcomeSeries],
146
- templates: registry,
156
+ templates,
147
157
  emails: { from: { email: 'noreply@acme.test', name: 'Acme' } },
148
158
  runtime: {
149
159
  development: { timeScale: 600, logMessages: true },
@@ -212,8 +222,8 @@ the routing table. Run it after every deploy that changes a journey.
212
222
  From the service that knows what users do:
213
223
 
214
224
  ```ts
215
- import { JourneyClient } from '@shware/workflow/client';
216
225
  import { contract } from '@acme/workflow-contract';
226
+ import { JourneyClient } from '@shware/workflow/client';
217
227
 
218
228
  export const journeys = new JourneyClient({
219
229
  contract,
@@ -296,10 +306,10 @@ trigger.webhook();
296
306
 
297
307
  | Step | What it does |
298
308
  | --------------------------------------------- | ------------------------------------------------------------------------- |
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 |
309
+ | `.email(email.k, props)` | Send an email |
310
+ | `.push(push.k, props, { devices? })` | Push to every device of the user, or only the `'latest'`; skipped if none |
311
+ | `.slack(slack.k, props)` | Post to a Slack incoming webhook |
312
+ | `.discord(discord.k, props)` | Post to a Discord webhook |
303
313
  | `.sms()`, `.inApp()`, `.survey()` | Message nodes with no built-in sender (see [Channels](#channels)) |
304
314
  | `.prefer([first, second], props, { label? })` | One push and one email: the first the user is reachable on |
305
315
  | `.delay('1 day')` | Sleep; `{ min, max }` sleeps a random time in between |
@@ -323,9 +333,9 @@ Both templates must declare the same props type.
323
333
 
324
334
  ```ts
325
335
  workflow({ id: 'welcome_series', trigger: trigger.event(e.login) })
326
- .email(t.email.welcome, { name: u.name })
336
+ .email(email.welcome, { name: u.name })
327
337
  .delay('1 day')
328
- .prefer([t.push.tip_push, t.email.tip], { name: u.name }, { label: 'tip by channel' });
338
+ .prefer([push.tip_push, email.tip], { name: u.name }, { label: 'tip by channel' });
329
339
  ```
330
340
 
331
341
  **timeWindow** takes `between: ['09:00', '17:00']` (24-hour, leading zeros, not
@@ -345,7 +355,7 @@ workflow({ id: 'notification_optin', trigger: trigger.event(e.login) }).waitUnti
345
355
  ),
346
356
  {
347
357
  timeout: '1 day',
348
- onTimeout: (w) => w.email(t.email.enable_notifications, { name: u.name }),
358
+ onTimeout: (w) => w.email(email.enable_notifications, { name: u.name }),
349
359
  }
350
360
  );
351
361
  ```
@@ -359,11 +369,11 @@ workflow({ id: 'nps_followup', trigger: trigger.event(e.nps_sent) }).branch(
359
369
  'by score',
360
370
  [
361
371
  performed(e.nps_sent, (p) => gte(p.score, 9)),
362
- (w) => w.delay('1 day').email(t.email.review_ask, { name: u.name }),
372
+ (w) => w.delay('1 day').email(email.review_ask, { name: u.name }),
363
373
  ],
364
374
  [
365
375
  performed(e.nps_sent, (p) => lte(p.score, 6)),
366
- (w) => w.slack(t.slack.nps_detractor, { name: u.name }).exit('handed to support'),
376
+ (w) => w.slack(slack.nps_detractor, { name: u.name }).exit('handed to support'),
367
377
  ]
368
378
  );
369
379
  ```
@@ -378,8 +388,8 @@ workflow({ id: 'checkout_recovery', trigger: trigger.event(e.begin_checkout) })
378
388
  .delay('1 day')
379
389
  .cohort(
380
390
  {
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 }) },
391
+ discount: { weight: 50, flow: (w) => w.email(email.offer_discount, { name: u.name }) },
392
+ social_proof: { weight: 50, flow: (w) => w.email(email.offer_reviews, { name: u.name }) },
383
393
  },
384
394
  { key: 'checkout_offer_v1' }
385
395
  );
@@ -465,7 +475,7 @@ emails: {
465
475
  ```
466
476
 
467
477
  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
478
+ `GET /preview/<id>?props=<JSON>` renders a template, with the subject in the
469
479
  `x-subject` header.
470
480
 
471
481
  ### Push
@@ -528,6 +538,7 @@ import { optional, strictObject, string } from 'zod/mini';
528
538
  import type { Env } from '../env';
529
539
 
530
540
  export default slackTemplate({
541
+ id: 'nps_detractor',
531
542
  title: 'NPS detractor (support)',
532
543
  props: strictObject({ name: string(), email: optional(string()) }),
533
544
  preview: { name: 'John Doe', email: 'john@example.com' },
@@ -544,9 +555,8 @@ post never holds up the messages after it.
544
555
 
545
556
  ### Other channels
546
557
 
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
558
+ `.sms()`, `.inApp()` and `.survey()` compile, and take a hand-typed reference
559
+ by key: `template.sms<P>(key)` (also `inApp`, `survey`). The config's channels deliver only email, push, Slack and
550
560
  Discord; supply anything else with `journeyWorker({ messages })`.
551
561
 
552
562
  ## Configuration
@@ -651,8 +661,8 @@ workflows actually changed. Deploy recomputes every declared hash and refuses a
651
661
  bundle whose hashes do not describe its contents.
652
662
 
653
663
  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.
664
+ after it. Template ids are stable identities too: an id in flight takes only
665
+ backward-compatible changes, so a new required prop means a new id.
656
666
 
657
667
  ## Cloudflare setup
658
668
 
package/dist/client.d.cts CHANGED
@@ -105,4 +105,5 @@ export declare class JourneyClient<C extends Contract | undefined = undefined> {
105
105
  private post;
106
106
  }
107
107
  //#endregion
108
+ export {};
108
109
  //# sourceMappingURL=client.d.cts.map
package/dist/client.d.mts CHANGED
@@ -105,4 +105,5 @@ export declare class JourneyClient<C extends Contract | undefined = undefined> {
105
105
  private post;
106
106
  }
107
107
  //#endregion
108
+ export {};
108
109
  //# sourceMappingURL=client.d.mts.map
@@ -112,24 +112,21 @@ function channelsFromConfig(config, env, options = {}) {
112
112
  * A webhook template's URL, read from the environment the Worker validated at startup (its
113
113
  * `env` schema): a template whose variable is unset fails the step, naming the template.
114
114
  */
115
- const webhookUrl = (channel, key, template) => {
115
+ const webhookUrl = (channel, template) => {
116
116
  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`);
117
+ if (typeof url !== "string" || url === "") throw new Error(`${channel} template '${template.id}': its webhook URL is not set in the environment`);
118
118
  return url;
119
119
  };
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
120
  const discord = () => {
125
121
  const registry = config.templates?.discord;
126
122
  if (registry === void 0) return unavailable("no discord templates are registered");
123
+ const index = require_templates.indexTemplates("discord", registry);
127
124
  return new require_webhooks.DiscordWebhookSender({
128
- render: (key, props) => {
129
- const template = lookupTemplate(registry, "discord", key);
125
+ render: (id, props) => {
126
+ const template = require_templates.lookupTemplate(index, "discord", id);
130
127
  return {
131
- url: webhookUrl("discord", key, template),
132
- content: require_templates.renderDiscordContent(template, key, props)
128
+ url: webhookUrl("discord", template),
129
+ content: require_templates.renderDiscordContent(template, props)
133
130
  };
134
131
  },
135
132
  ...options.fetch === void 0 ? {} : { fetch: options.fetch }
@@ -138,12 +135,13 @@ function channelsFromConfig(config, env, options = {}) {
138
135
  const slack = () => {
139
136
  const registry = config.templates?.slack;
140
137
  if (registry === void 0) return unavailable("no slack templates are registered");
138
+ const index = require_templates.indexTemplates("slack", registry);
141
139
  return new require_webhooks.SlackWebhookSender({
142
- render: (key, props) => {
143
- const template = lookupTemplate(registry, "slack", key);
140
+ render: (id, props) => {
141
+ const template = require_templates.lookupTemplate(index, "slack", id);
144
142
  return {
145
- url: webhookUrl("slack", key, template),
146
- content: require_templates.renderSlackContent(template, key, props)
143
+ url: webhookUrl("slack", template),
144
+ content: require_templates.renderSlackContent(template, props)
147
145
  };
148
146
  },
149
147
  ...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' | '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 = (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 +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 +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"}
@@ -1,5 +1,5 @@
1
1
  import { ApnsPushSender, apnsKeyFromBase64 } from "../apns.mjs";
2
- import { renderDiscordContent, renderSlackContent } from "../templates.mjs";
2
+ import { indexTemplates, lookupTemplate, renderDiscordContent, renderSlackContent } from "../templates.mjs";
3
3
  import { apnsEnvironment } from "../config.mjs";
4
4
  import { FcmPushSender, serviceAccountFromBase64 } from "../fcm.mjs";
5
5
  import { resolveRuntime } from "../runtime.mjs";
@@ -111,24 +111,21 @@ function channelsFromConfig(config, env, options = {}) {
111
111
  * A webhook template's URL, read from the environment the Worker validated at startup (its
112
112
  * `env` schema): a template whose variable is unset fails the step, naming the template.
113
113
  */
114
- const webhookUrl = (channel, key, template) => {
114
+ const webhookUrl = (channel, template) => {
115
115
  const url = template.webhook(env);
116
- if (typeof url !== "string" || url === "") throw new Error(`${channel} template '${key}': its webhook URL is not set in the environment`);
116
+ if (typeof url !== "string" || url === "") throw new Error(`${channel} template '${template.id}': its webhook URL is not set in the environment`);
117
117
  return url;
118
118
  };
119
- const lookupTemplate = (registry, channel, key) => {
120
- if (!Object.hasOwn(registry, key)) throw new Error(`unknown ${channel} template: ${key}`);
121
- return registry[key];
122
- };
123
119
  const discord = () => {
124
120
  const registry = config.templates?.discord;
125
121
  if (registry === void 0) return unavailable("no discord templates are registered");
122
+ const index = indexTemplates("discord", registry);
126
123
  return new DiscordWebhookSender({
127
- render: (key, props) => {
128
- const template = lookupTemplate(registry, "discord", key);
124
+ render: (id, props) => {
125
+ const template = lookupTemplate(index, "discord", id);
129
126
  return {
130
- url: webhookUrl("discord", key, template),
131
- content: renderDiscordContent(template, key, props)
127
+ url: webhookUrl("discord", template),
128
+ content: renderDiscordContent(template, props)
132
129
  };
133
130
  },
134
131
  ...options.fetch === void 0 ? {} : { fetch: options.fetch }
@@ -137,12 +134,13 @@ function channelsFromConfig(config, env, options = {}) {
137
134
  const slack = () => {
138
135
  const registry = config.templates?.slack;
139
136
  if (registry === void 0) return unavailable("no slack templates are registered");
137
+ const index = indexTemplates("slack", registry);
140
138
  return new SlackWebhookSender({
141
- render: (key, props) => {
142
- const template = lookupTemplate(registry, "slack", key);
139
+ render: (id, props) => {
140
+ const template = lookupTemplate(index, "slack", id);
143
141
  return {
144
- url: webhookUrl("slack", key, template),
145
- content: renderSlackContent(template, key, props)
142
+ url: webhookUrl("slack", template),
143
+ content: renderSlackContent(template, props)
146
144
  };
147
145
  },
148
146
  ...options.fetch === void 0 ? {} : { fetch: options.fetch }
@@ -1 +1 @@
1
- {"version":3,"file":"channels.mjs","names":[],"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.mjs","names":[],"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' | '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 = (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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"}
package/dist/config.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_runtime = require("./_virtual/_rolldown/runtime.cjs");
3
- const require_dsl_template = require("./dsl/template.cjs");
3
+ const require_templates = require("./templates.cjs");
4
+ const require_dsl_flow = require("./dsl/flow.cjs");
4
5
  const require_dsl_bundle = require("./dsl/bundle.cjs");
5
6
  require("./dsl/index.cjs");
6
7
  let zod_mini = require("zod/mini");
@@ -30,28 +31,40 @@ var ConfigCompileError = class extends Error {
30
31
  };
31
32
  /**
32
33
  * Compile the config: the bundle `compileBundle` would produce, with the
33
- * template manifest generated from the registries (every registered template,
34
- * not a hand-kept list), after checking every message node against them —
35
- * an unregistered or cross-channel template reference fails with the workflow,
36
- * node and channel named, and so does a literal prop the template's schema
37
- * refuses (a missing required prop, an undeclared one, a wrong literal).
38
- * User-property references cannot be parsed here (their values exist only at
39
- * send time) — they are checked by name only, and parsed before sending.
34
+ * template manifest generated from the registry (every registered template,
35
+ * by id, not a hand-kept list). The registry is checked first, used or not:
36
+ * every entry is a template of its channel with an id, and an id is registered
37
+ * once per channel. Then every message node is checked against it — an
38
+ * unregistered or cross-channel template fails with the workflow, node and
39
+ * channel named, and so does a literal prop the template's schema refuses (a
40
+ * missing required prop, an undeclared one, a wrong literal) — and every
41
+ * definition a workflow sends must be the very object registered under its id,
42
+ * not merely one with the same id. User-property references cannot be parsed
43
+ * here (their values exist only at send time) — they are checked by name only,
44
+ * and parsed before sending.
40
45
  */
41
46
  function compileConfig(config) {
42
- const registered = (channel) => Object.keys(registryOf(config, channel) ?? {});
47
+ let index;
48
+ try {
49
+ index = {
50
+ email: require_templates.indexTemplates("email", config.templates?.email),
51
+ push: require_templates.indexTemplates("push", config.templates?.push),
52
+ discord: require_templates.indexTemplates("discord", config.templates?.discord),
53
+ slack: require_templates.indexTemplates("slack", config.templates?.slack)
54
+ };
55
+ } catch (error) {
56
+ throw new ConfigCompileError(error.message);
57
+ }
43
58
  const bundle = require_dsl_bundle.compileBundle({
44
59
  workflows: config.workflows,
45
60
  ...config.segments === void 0 ? {} : { segments: config.segments },
46
61
  ...config.actions === void 0 ? {} : { actions: config.actions },
47
- templates: [
48
- ...registered("email").map((key) => require_dsl_template.template.email(key)),
49
- ...registered("push").map((key) => require_dsl_template.template.push(key)),
50
- ...registered("discord").map((key) => require_dsl_template.template.discord(key)),
51
- ...registered("slack").map((key) => require_dsl_template.template.slack(key))
52
- ]
62
+ templates: TEMPLATE_CHANNELS.flatMap((channel) => [...index[channel].values()])
63
+ });
64
+ bundle.workflows.forEach((workflow, i) => {
65
+ checkMessages(workflow, workflow.flow, index, config);
66
+ checkUses(workflow, config.workflows[i], index, config);
53
67
  });
54
- for (const workflow of bundle.workflows) checkMessages(workflow, workflow.flow, config);
55
68
  return bundle;
56
69
  }
57
70
  const TEMPLATE_CHANNELS = [
@@ -60,14 +73,25 @@ const TEMPLATE_CHANNELS = [
60
73
  "discord",
61
74
  "slack"
62
75
  ];
63
- /** The config's registry for a channel; undefined for a channel templates cannot be declared for. */
64
- function registryOf(config, channel) {
65
- switch (channel) {
66
- case "email": return config.templates?.email;
67
- case "push": return config.templates?.push;
68
- case "discord": return config.templates?.discord;
69
- case "slack": return config.templates?.slack;
70
- default: return;
76
+ /** The channel's templates by id; undefined for a channel templates cannot be declared for. */
77
+ function indexOf(index, channel) {
78
+ return TEMPLATE_CHANNELS.includes(channel) ? index[channel] : void 0;
79
+ }
80
+ /**
81
+ * The definitions a workflow sends against the registered ones: same id is not enough — a
82
+ * workflow typed by one definition and a registry holding another under that id would render
83
+ * with a schema the call was never checked against.
84
+ */
85
+ function checkUses(workflow, builder, index, config) {
86
+ for (const { template, loc } of require_dsl_flow.templateUses(builder)) {
87
+ const channel = require_templates.CHANNEL_OF[template.kind];
88
+ const registered = index[channel].get(template.id);
89
+ if (registered === template) continue;
90
+ const at = loc === void 0 ? "" : ` (${loc.file}:${loc.line})`;
91
+ const where = `workflow '${workflow.id}'${at}: ${channel} template '${template.id}'`;
92
+ if (registered === void 0) throw new ConfigCompileError(`${where} is not registered`);
93
+ const name = Object.entries(config.templates?.[channel] ?? {}).find(([, entry]) => entry === registered)?.[0];
94
+ throw new ConfigCompileError(`${where} is not the definition registered under that id (templates.${channel}.${name}) — two definitions share the id`);
71
95
  }
72
96
  }
73
97
  /** A field that may be left out: optional, or with a default the schema fills in. */
@@ -75,34 +99,35 @@ function canBeOmitted(schema) {
75
99
  const type = schema._zod.def.type;
76
100
  return type === "optional" || type === "default" || type === "prefault";
77
101
  }
78
- function checkMessages(workflow, nodes, config) {
102
+ function checkMessages(workflow, nodes, index, config) {
79
103
  for (const node of nodes) switch (node.type) {
80
104
  case "message":
81
- checkMessage(workflow, node, config);
105
+ checkMessage(workflow, node, index, config);
82
106
  break;
83
107
  case "send_event":
84
108
  checkEmit(workflow, node, config);
85
109
  break;
86
110
  case "branch":
87
- for (const c of node.cases) checkMessages(workflow, c.flow, config);
88
- if (node.otherwise) checkMessages(workflow, node.otherwise, config);
111
+ for (const c of node.cases) checkMessages(workflow, c.flow, index, config);
112
+ if (node.otherwise) checkMessages(workflow, node.otherwise, index, config);
89
113
  break;
90
114
  case "cohort":
91
- for (const arm of node.arms) checkMessages(workflow, arm.flow, config);
115
+ for (const arm of node.arms) checkMessages(workflow, arm.flow, index, config);
92
116
  break;
93
- case "wait_until": if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, config);
117
+ case "wait_until": if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, index, config);
94
118
  }
95
119
  }
96
- function checkMessage(workflow, node, config) {
120
+ function checkMessage(workflow, node, index, config) {
97
121
  const where = `workflow '${workflow.id}' node ${node.id}`;
98
- const registry = registryOf(config, node.channel);
99
- if (registry === void 0 && !TEMPLATE_CHANNELS.includes(node.channel)) return;
100
- if (registry === void 0 || !Object.hasOwn(registry, node.template)) {
101
- const other = TEMPLATE_CHANNELS.find((channel) => channel !== node.channel && Object.hasOwn(registryOf(config, channel) ?? {}, node.template));
122
+ const registry = indexOf(index, node.channel);
123
+ if (registry === void 0) return;
124
+ const entry = registry.get(node.template);
125
+ if (entry === void 0) {
126
+ const other = TEMPLATE_CHANNELS.find((channel) => channel !== node.channel && index[channel].has(node.template));
102
127
  const hint = other === void 0 ? "" : ` (it is registered as a ${other} template)`;
103
128
  throw new ConfigCompileError(`${where}: ${node.channel} template '${node.template}' is not registered${hint}`);
104
129
  }
105
- const shape = registry[node.template].props._zod.def.shape;
130
+ const shape = entry.props._zod.def.shape;
106
131
  const problems = [];
107
132
  for (const [field, value] of Object.entries(node.props)) {
108
133
  if (!Object.hasOwn(shape, field)) {