@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.
- package/README.md +48 -38
- package/dist/client.d.cts +1 -0
- package/dist/client.d.mts +1 -0
- package/dist/cloudflare/channels.cjs +12 -14
- package/dist/cloudflare/channels.cjs.map +1 -1
- package/dist/cloudflare/channels.d.cts.map +1 -1
- package/dist/cloudflare/channels.d.mts.map +1 -1
- package/dist/cloudflare/channels.mjs +13 -15
- package/dist/cloudflare/channels.mjs.map +1 -1
- package/dist/config.cjs +61 -36
- package/dist/config.cjs.map +1 -1
- package/dist/config.d.cts +19 -15
- package/dist/config.d.cts.map +1 -1
- package/dist/config.d.mts +19 -15
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +61 -36
- package/dist/config.mjs.map +1 -1
- package/dist/contract.d.cts +1 -0
- package/dist/contract.d.mts +1 -0
- package/dist/dsl/action.cjs +1 -1
- package/dist/dsl/action.cjs.map +1 -1
- package/dist/dsl/action.d.cts +1 -1
- package/dist/dsl/action.d.mts +1 -1
- package/dist/dsl/action.mjs +1 -1
- package/dist/dsl/action.mjs.map +1 -1
- package/dist/dsl/bundle.cjs +3 -2
- package/dist/dsl/bundle.cjs.map +1 -1
- package/dist/dsl/bundle.d.cts +3 -2
- package/dist/dsl/bundle.d.cts.map +1 -1
- package/dist/dsl/bundle.d.mts +3 -2
- package/dist/dsl/bundle.d.mts.map +1 -1
- package/dist/dsl/bundle.mjs +3 -2
- package/dist/dsl/bundle.mjs.map +1 -1
- package/dist/dsl/condition.cjs +1 -1
- package/dist/dsl/condition.cjs.map +1 -1
- package/dist/dsl/condition.d.cts +2 -1
- package/dist/dsl/condition.d.mts +2 -1
- package/dist/dsl/condition.mjs +1 -1
- package/dist/dsl/condition.mjs.map +1 -1
- package/dist/dsl/flow.cjs +45 -17
- package/dist/dsl/flow.cjs.map +1 -1
- package/dist/dsl/flow.d.cts +31 -17
- package/dist/dsl/flow.d.cts.map +1 -1
- package/dist/dsl/flow.d.mts +31 -17
- package/dist/dsl/flow.d.mts.map +1 -1
- package/dist/dsl/flow.mjs +45 -18
- package/dist/dsl/flow.mjs.map +1 -1
- package/dist/dsl/index.cjs +0 -1
- package/dist/dsl/index.d.cts +2 -2
- package/dist/dsl/index.d.mts +2 -2
- package/dist/dsl/index.mjs +2 -2
- package/dist/dsl/template.cjs +2 -47
- package/dist/dsl/template.cjs.map +1 -1
- package/dist/dsl/template.d.cts +4 -53
- package/dist/dsl/template.d.cts.map +1 -1
- package/dist/dsl/template.d.mts +4 -53
- package/dist/dsl/template.d.mts.map +1 -1
- package/dist/dsl/template.mjs +3 -47
- package/dist/dsl/template.mjs.map +1 -1
- package/dist/dsl/workflow.cjs +1 -1
- package/dist/dsl/workflow.cjs.map +1 -1
- package/dist/dsl/workflow.d.cts +1 -1
- package/dist/dsl/workflow.d.mts +1 -1
- package/dist/dsl/workflow.mjs +1 -1
- package/dist/dsl/workflow.mjs.map +1 -1
- package/dist/index.cjs +5 -2
- package/dist/index.d.cts +3 -3
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +3 -3
- package/dist/ir.d.cts +1 -0
- package/dist/ir.d.mts +1 -0
- package/dist/provenance.d.cts +1 -0
- package/dist/provenance.d.mts +1 -0
- package/dist/push.cjs +3 -5
- package/dist/push.cjs.map +1 -1
- package/dist/push.d.cts +2 -2
- package/dist/push.d.cts.map +1 -1
- package/dist/push.d.mts +2 -2
- package/dist/push.d.mts.map +1 -1
- package/dist/push.mjs +4 -6
- package/dist/push.mjs.map +1 -1
- package/dist/react-email.cjs +5 -5
- package/dist/react-email.cjs.map +1 -1
- package/dist/react-email.d.cts +1 -1
- package/dist/react-email.d.cts.map +1 -1
- package/dist/react-email.d.mts +1 -1
- package/dist/react-email.d.mts.map +1 -1
- package/dist/react-email.mjs +6 -6
- package/dist/react-email.mjs.map +1 -1
- package/dist/templates.cjs +84 -29
- package/dist/templates.cjs.map +1 -1
- package/dist/templates.d.cts +50 -11
- package/dist/templates.d.cts.map +1 -1
- package/dist/templates.d.mts +50 -11
- package/dist/templates.d.mts.map +1 -1
- package/dist/templates.mjs +81 -30
- package/dist/templates.mjs.map +1 -1
- package/dist/webpush.d.cts +1 -0
- package/dist/webpush.d.mts +1 -0
- 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(
|
|
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(
|
|
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
|
|
72
|
-
the
|
|
73
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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 {
|
|
100
|
-
import welcome from './email/welcome';
|
|
101
|
-
import tip from './email/tip';
|
|
110
|
+
import { email } from './email';
|
|
102
111
|
|
|
103
|
-
export const
|
|
104
|
-
export const t = templates(registry);
|
|
112
|
+
export const templates = { email };
|
|
105
113
|
```
|
|
106
114
|
|
|
107
|
-
`tip.tsx` is written the same way. The
|
|
108
|
-
|
|
109
|
-
|
|
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(
|
|
135
|
+
.email(email.welcome, { name: u.name })
|
|
126
136
|
.delay('2 days')
|
|
127
|
-
.branch([not(performed(e.purchase)), (w) => w.email(
|
|
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 {
|
|
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
|
|
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(
|
|
300
|
-
| `.push(
|
|
301
|
-
| `.slack(
|
|
302
|
-
| `.discord(
|
|
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(
|
|
336
|
+
.email(email.welcome, { name: u.name })
|
|
327
337
|
.delay('1 day')
|
|
328
|
-
.prefer([
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
382
|
-
social_proof: { weight: 50, flow: (w) => w.email(
|
|
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/<
|
|
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
|
|
548
|
-
|
|
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
|
|
655
|
-
backward-compatible changes, so a new required prop means a new
|
|
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
package/dist/client.d.mts
CHANGED
|
@@ -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,
|
|
115
|
+
const webhookUrl = (channel, template) => {
|
|
116
116
|
const url = template.webhook(env);
|
|
117
|
-
if (typeof url !== "string" || url === "") throw new Error(`${channel} template '${
|
|
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: (
|
|
129
|
-
const template = lookupTemplate(
|
|
125
|
+
render: (id, props) => {
|
|
126
|
+
const template = require_templates.lookupTemplate(index, "discord", id);
|
|
130
127
|
return {
|
|
131
|
-
url: webhookUrl("discord",
|
|
132
|
-
content: require_templates.renderDiscordContent(template,
|
|
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: (
|
|
143
|
-
const template = lookupTemplate(
|
|
140
|
+
render: (id, props) => {
|
|
141
|
+
const template = require_templates.lookupTemplate(index, "slack", id);
|
|
144
142
|
return {
|
|
145
|
-
url: webhookUrl("slack",
|
|
146
|
-
content: require_templates.renderSlackContent(template,
|
|
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":";;;;;;;;;;;;;;;;;
|
|
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":";;;;;;;;;;;;;;;;;
|
|
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,
|
|
114
|
+
const webhookUrl = (channel, template) => {
|
|
115
115
|
const url = template.webhook(env);
|
|
116
|
-
if (typeof url !== "string" || url === "") throw new Error(`${channel} template '${
|
|
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: (
|
|
128
|
-
const template = lookupTemplate(
|
|
124
|
+
render: (id, props) => {
|
|
125
|
+
const template = lookupTemplate(index, "discord", id);
|
|
129
126
|
return {
|
|
130
|
-
url: webhookUrl("discord",
|
|
131
|
-
content: renderDiscordContent(template,
|
|
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: (
|
|
142
|
-
const template = lookupTemplate(
|
|
139
|
+
render: (id, props) => {
|
|
140
|
+
const template = lookupTemplate(index, "slack", id);
|
|
143
141
|
return {
|
|
144
|
-
url: webhookUrl("slack",
|
|
145
|
-
content: renderSlackContent(template,
|
|
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
|
|
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
|
|
34
|
-
* not a hand-kept list)
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
64
|
-
function
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 =
|
|
99
|
-
if (registry === void 0
|
|
100
|
-
|
|
101
|
-
|
|
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 =
|
|
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)) {
|