@shware/workflow 3.1.0 → 3.3.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 +13 -2
- package/dist/aws/engine-step.cjs +2 -3
- package/dist/aws/engine-step.cjs.map +1 -1
- package/dist/aws/engine-step.d.cts +10 -0
- package/dist/aws/engine-step.d.cts.map +1 -1
- package/dist/aws/engine-step.d.mts +10 -0
- package/dist/aws/engine-step.d.mts.map +1 -1
- package/dist/aws/engine-step.mjs +2 -3
- package/dist/aws/engine-step.mjs.map +1 -1
- package/dist/aws/index.cjs +9 -1
- package/dist/aws/index.cjs.map +1 -1
- package/dist/aws/index.d.cts +6 -0
- package/dist/aws/index.d.cts.map +1 -1
- package/dist/aws/index.d.mts +6 -0
- package/dist/aws/index.d.mts.map +1 -1
- package/dist/aws/index.mjs +9 -1
- package/dist/aws/index.mjs.map +1 -1
- package/dist/cloudflare/bindings.cjs.map +1 -1
- package/dist/cloudflare/bindings.d.cts +2 -0
- package/dist/cloudflare/bindings.d.cts.map +1 -1
- package/dist/cloudflare/bindings.d.mts +2 -0
- package/dist/cloudflare/bindings.d.mts.map +1 -1
- package/dist/cloudflare/bindings.mjs.map +1 -1
- package/dist/cloudflare/runner.cjs +4 -5
- package/dist/cloudflare/runner.cjs.map +1 -1
- package/dist/cloudflare/runner.d.cts.map +1 -1
- package/dist/cloudflare/runner.d.mts.map +1 -1
- package/dist/cloudflare/runner.mjs +4 -5
- package/dist/cloudflare/runner.mjs.map +1 -1
- package/dist/email-sender.cjs +24 -0
- package/dist/email-sender.cjs.map +1 -1
- package/dist/email-sender.d.cts +18 -0
- package/dist/email-sender.d.cts.map +1 -1
- package/dist/email-sender.d.mts +18 -0
- package/dist/email-sender.d.mts.map +1 -1
- package/dist/email-sender.mjs +24 -1
- package/dist/email-sender.mjs.map +1 -1
- package/dist/engine/duration.cjs +34 -0
- package/dist/engine/duration.cjs.map +1 -0
- package/dist/engine/duration.d.cts +5 -0
- package/dist/engine/duration.d.cts.map +1 -0
- package/dist/engine/duration.d.mts +5 -0
- package/dist/engine/duration.d.mts.map +1 -0
- package/dist/engine/duration.mjs +33 -0
- package/dist/engine/duration.mjs.map +1 -0
- package/dist/engine/index.cjs +5 -0
- package/dist/engine/index.d.cts +3 -1
- package/dist/engine/index.d.mts +3 -1
- package/dist/engine/index.mjs +3 -1
- package/dist/engine/ports.d.cts +6 -0
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +6 -0
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/engine/retry.cjs +29 -0
- package/dist/engine/retry.cjs.map +1 -0
- package/dist/engine/retry.d.cts +17 -0
- package/dist/engine/retry.d.cts.map +1 -0
- package/dist/engine/retry.d.mts +17 -0
- package/dist/engine/retry.d.mts.map +1 -0
- package/dist/engine/retry.mjs +27 -0
- package/dist/engine/retry.mjs.map +1 -0
- package/dist/host/journey-host.cjs +15 -0
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +15 -0
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/reporting/observer.cjs +1 -0
- package/dist/reporting/observer.cjs.map +1 -1
- package/dist/reporting/observer.mjs +1 -0
- package/dist/reporting/observer.mjs.map +1 -1
- package/dist/reporting/tracking.cjs +7 -2
- package/dist/reporting/tracking.cjs.map +1 -1
- package/dist/reporting/tracking.d.cts +3 -1
- package/dist/reporting/tracking.d.cts.map +1 -1
- package/dist/reporting/tracking.d.mts +3 -1
- package/dist/reporting/tracking.d.mts.map +1 -1
- package/dist/reporting/tracking.mjs +7 -2
- package/dist/reporting/tracking.mjs.map +1 -1
- package/dist/transports/cloudflare-email.cjs +6 -1
- package/dist/transports/cloudflare-email.cjs.map +1 -1
- package/dist/transports/cloudflare-email.d.cts +2 -0
- package/dist/transports/cloudflare-email.d.cts.map +1 -1
- package/dist/transports/cloudflare-email.d.mts +2 -0
- package/dist/transports/cloudflare-email.d.mts.map +1 -1
- package/dist/transports/cloudflare-email.mjs +6 -1
- package/dist/transports/cloudflare-email.mjs.map +1 -1
- package/dist/transports/ses.cjs +9 -1
- package/dist/transports/ses.cjs.map +1 -1
- package/dist/transports/ses.d.cts +2 -0
- package/dist/transports/ses.d.cts.map +1 -1
- package/dist/transports/ses.d.mts +2 -0
- package/dist/transports/ses.d.mts.map +1 -1
- package/dist/transports/ses.mjs +9 -1
- package/dist/transports/ses.mjs.map +1 -1
- package/dist/transports/sigv4.cjs +6 -1
- package/dist/transports/sigv4.cjs.map +1 -1
- package/dist/transports/sigv4.d.cts.map +1 -1
- package/dist/transports/sigv4.d.mts.map +1 -1
- package/dist/transports/sigv4.mjs +6 -1
- package/dist/transports/sigv4.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_runtime = require("../runtime.cjs");
|
|
3
|
+
const require_engine_retry = require("../engine/retry.cjs");
|
|
3
4
|
const require_cloudflare_bindings = require("./bindings.cjs");
|
|
4
5
|
const require_cloudflare_d1_env = require("./d1-env.cjs");
|
|
5
6
|
const require_host_execute = require("../host/execute.cjs");
|
|
@@ -15,8 +16,6 @@ let cloudflare_workflows = require("cloudflare:workflows");
|
|
|
15
16
|
* waitForEvent, which signals a timeout by throwing — and so does every other
|
|
16
17
|
* failure in there, so only the clock can tell them apart (see wakeExpired).
|
|
17
18
|
*/
|
|
18
|
-
/** Refusals a retry would only repeat: a template's props, an emitted event's payload. */
|
|
19
|
-
const NON_RETRYABLE = /* @__PURE__ */ new Set(["TemplateValidationError", "ContractValidationError"]);
|
|
20
19
|
var CfEngineStep = class {
|
|
21
20
|
step;
|
|
22
21
|
store;
|
|
@@ -29,8 +28,8 @@ var CfEngineStep = class {
|
|
|
29
28
|
this.instanceId = instanceId;
|
|
30
29
|
}
|
|
31
30
|
/**
|
|
32
|
-
* A
|
|
33
|
-
*
|
|
31
|
+
* A failure a retry would only repeat (`isRetryable`: a template refusing its props, the
|
|
32
|
+
* contract refusing an emitted event, a provider rejecting the request) ends the step at
|
|
34
33
|
* once instead of burning the retry budget.
|
|
35
34
|
*/
|
|
36
35
|
do(name, fn) {
|
|
@@ -38,7 +37,7 @@ var CfEngineStep = class {
|
|
|
38
37
|
try {
|
|
39
38
|
return await fn();
|
|
40
39
|
} catch (error) {
|
|
41
|
-
if (error instanceof Error &&
|
|
40
|
+
if (error instanceof Error && !require_engine_retry.isRetryable(error)) throw new cloudflare_workflows.NonRetryableError(error.message, error.name);
|
|
42
41
|
throw error;
|
|
43
42
|
}
|
|
44
43
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runner.cjs","names":["NonRetryableError","WAKE_EVENT_TYPE","wakeExpired","WorkflowEntrypoint","d1Store","resolveRuntime","WebhookMessageSender","LogMessageSender","RegistryActionInvoker","executeJourney"],"sources":["../../src/cloudflare/runner.ts"],"sourcesContent":["import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers';\nimport { NonRetryableError } from 'cloudflare:workflows';\nimport type { Contract } from '../contract';\nimport { type RegisteredAction, RegistryActionInvoker } from '../engine/actions';\nimport type {\n ActionInvoker,\n EngineStep,\n JourneyObserver,\n JourneyOutcome,\n MessageSender,\n MessageValidator,\n} from '../engine/ports';\nimport { type JourneyRunParts, executeJourney } from '../host/execute';\nimport { LogMessageSender, WebhookMessageSender } from '../host/senders';\nimport { type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport { type JourneyEnv, type JourneyParams, WAKE_EVENT_TYPE, wakeExpired } from './bindings';\nimport { d1Store } from './d1-env';\n\n/**\n * Adapts CF's WorkflowStep to EngineStep.\n * subscribe/unsubscribe write the subscription table inside durable steps (the\n * router wakes instances from that table); waitForWake parks on CF's\n * waitForEvent, which signals a timeout by throwing — and so does every other\n * failure in there, so only the clock can tell them apart (see wakeExpired).\n */\n/** Refusals a retry would only repeat: a template's props, an emitted event's payload. */\nconst NON_RETRYABLE = new Set(['TemplateValidationError', 'ContractValidationError']);\n\nclass CfEngineStep implements EngineStep {\n constructor(\n private readonly step: WorkflowStep,\n private readonly store: JourneyStore,\n private readonly userId: string,\n private readonly instanceId: string\n ) {}\n\n /**\n * A template refusing its props, or the contract refusing an emitted event,\n * is deterministic — a retry hits the same refusal — so it ends the step at\n * once instead of burning the retry budget.\n */\n do<T>(name: string, fn: () => Promise<T>): Promise<T> {\n return this.step.do(name, async () => {\n try {\n return await fn();\n } catch (error) {\n if (error instanceof Error && NON_RETRYABLE.has(error.name)) {\n throw new NonRetryableError(error.message, error.name);\n }\n throw error;\n }\n });\n }\n\n sleep(name: string, ms: number): Promise<void> {\n return this.step.sleep(name, ms);\n }\n\n sleepUntil(name: string, timestampMs: number): Promise<void> {\n return this.step.sleepUntil(name, timestampMs);\n }\n\n subscribe(name: string, events: readonly string[]): Promise<void> {\n // Idempotent in the store: the interpreter re-subscribes on every wait\n // attempt (one-shot-callback contract) and registrations persist here.\n return this.step.do(name, () =>\n this.store.subscribe(this.instanceId, this.userId, events, Date.now())\n );\n }\n\n unsubscribe(name: string): Promise<void> {\n return this.step.do(name, () => this.store.unsubscribe(this.instanceId));\n }\n\n async waitForWake(name: string, timeoutMs: number): Promise<'event' | 'timeout'> {\n const startedAt = Date.now();\n try {\n await this.step.waitForEvent(name, { type: WAKE_EVENT_TYPE, timeout: timeoutMs });\n return 'event';\n } catch (error) {\n /*\n * Only the deadline can tell a real timeout from a failure (see\n * wakeExpired). `startedAt` is not replay-stable, but it is read only\n * inside the step body below, and a step body runs on the live attempt\n * alone — a replay returns the persisted verdict without touching the\n * clock.\n */\n const timedOut = await this.step.do(`${name}:timed-out`, async () =>\n wakeExpired(startedAt, timeoutMs, Date.now())\n );\n if (timedOut) return 'timeout';\n throw error;\n }\n }\n}\n\n/**\n * The generic journey executor: one class runs every workflow, because IR is\n * data. It is loaded from the store by the contentHash pinned at entry —\n * content addressing makes it immutable, so reading it outside a step is\n * replay-safe.\n */\nexport class JourneyRunner extends WorkflowEntrypoint<JourneyEnv, JourneyParams> {\n /**\n * The data plane. D1 + KV from the env by default; an app on Postgres\n * overrides this (and `closeStore` if its client needs closing):\n *\n * protected override createStore() { return new PostgresJourneyStore(client(this.env)); }\n */\n protected createStore(): JourneyStore {\n return d1Store(this.env);\n }\n\n /** Called once `run` is over, success or not — release whatever `createStore` opened. */\n protected async closeStore(_store: JourneyStore): Promise<void> {}\n\n /**\n * Runtime options for this instance — typed configuration, resolved by the app (typically\n * `resolveRuntime(config.runtime, mode)` over its workflow.config.ts, with the mode coming from\n * an ENVIRONMENT var). The instance params are passed so the decision can also be per user:\n * a tester account in production can run in fast-forward while everyone else runs in real\n * time. Defaults: real time, quiet.\n *\n * protected override runtime({ userId }) {\n * return resolveRuntime(workflowConfig.runtime, isTester(userId) ? 'test' : this.env.ENVIRONMENT);\n * }\n */\n protected runtime(_params: JourneyParams): JourneyRuntimeOptions {\n return resolveRuntime(undefined, 'production');\n }\n\n /**\n * Message outlet; an app overrides this to plug in real channels (say\n * an EmailSender over a transport with a react-email renderer). By default it picks the webhook\n * or logging sender based on the environment.\n */\n protected createMessageSender(): MessageSender {\n return this.env.MESSAGE_WEBHOOK_URL\n ? new WebhookMessageSender(this.env.MESSAGE_WEBHOOK_URL)\n : new LogMessageSender();\n }\n\n /**\n * Custom-action registry; an app overrides this returning the same\n * `action(...)` objects its workflows reference (an ActionRef is a\n * RegisteredAction structurally) — the code plane of the dual-plane model:\n *\n * protected override actions() { return [syncCrm, issueCoupon]; }\n */\n protected actions(): readonly RegisteredAction[] {\n return [];\n }\n\n /**\n * The contract events this journey emits (`.sendEvent()`) are checked\n * against, exactly like ingest; journeyWorker passes its config's.\n */\n protected contract(): Contract | undefined {\n return undefined;\n }\n\n /**\n * Template props check before every send; an app overrides this with\n * `registryValidator({ emails, pushes })` (journeyWorker does it from its\n * registries). By default props go out as resolved.\n */\n protected createMessageValidator(): MessageValidator | undefined {\n return undefined;\n }\n\n /**\n * Reporting hooks: node arrivals, every copy sent, how each instance ended. The host stores\n * what it needs for its dashboards; by default nothing is reported.\n */\n protected createObserver(_store: JourneyStore): JourneyObserver | undefined {\n return undefined;\n }\n\n /** Override for a different version policy ('warn' by default) or a fully custom invoker (e.g. webhook degradation for hosted tenants). */\n protected createActionInvoker(): ActionInvoker {\n return new RegistryActionInvoker(this.actions());\n }\n\n /**\n * Everything one run is wired to. By default it is composed from the methods above; a host\n * that assembles the whole set itself (journeyWorker) overrides this one instead.\n */\n protected createRunParts(store: JourneyStore, params: JourneyParams): JourneyRunParts {\n return {\n store,\n env: this.env,\n runtime: this.runtime(params),\n messages: this.createMessageSender(),\n actions: this.createActionInvoker(),\n contract: this.contract(),\n validator: this.createMessageValidator(),\n observer: this.createObserver(store),\n };\n }\n\n async run(event: WorkflowEvent<JourneyParams>, step: WorkflowStep): Promise<JourneyOutcome> {\n const store = this.createStore();\n try {\n const { userId } = event.payload;\n return await executeJourney(\n {\n instanceId: event.instanceId,\n params: event.payload,\n // Trigger-event creation time: replay-stable, unlike Date.now() here\n enteredAtMs: event.timestamp.getTime(),\n step: new CfEngineStep(step, store, userId, event.instanceId),\n },\n this.createRunParts(store, event.payload)\n );\n } finally {\n await this.closeStore(store);\n }\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"runner.cjs","names":["isRetryable","NonRetryableError","WAKE_EVENT_TYPE","wakeExpired","WorkflowEntrypoint","d1Store","resolveRuntime","WebhookMessageSender","LogMessageSender","RegistryActionInvoker","executeJourney"],"sources":["../../src/cloudflare/runner.ts"],"sourcesContent":["import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers';\nimport { NonRetryableError } from 'cloudflare:workflows';\nimport type { Contract } from '../contract';\nimport { type RegisteredAction, RegistryActionInvoker } from '../engine/actions';\nimport type {\n ActionInvoker,\n EngineStep,\n JourneyObserver,\n JourneyOutcome,\n MessageSender,\n MessageValidator,\n} from '../engine/ports';\nimport { isRetryable } from '../engine/retry';\nimport { type JourneyRunParts, executeJourney } from '../host/execute';\nimport { LogMessageSender, WebhookMessageSender } from '../host/senders';\nimport { type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport { type JourneyEnv, type JourneyParams, WAKE_EVENT_TYPE, wakeExpired } from './bindings';\nimport { d1Store } from './d1-env';\n\n/**\n * Adapts CF's WorkflowStep to EngineStep.\n * subscribe/unsubscribe write the subscription table inside durable steps (the\n * router wakes instances from that table); waitForWake parks on CF's\n * waitForEvent, which signals a timeout by throwing — and so does every other\n * failure in there, so only the clock can tell them apart (see wakeExpired).\n */\nclass CfEngineStep implements EngineStep {\n constructor(\n private readonly step: WorkflowStep,\n private readonly store: JourneyStore,\n private readonly userId: string,\n private readonly instanceId: string\n ) {}\n\n /**\n * A failure a retry would only repeat (`isRetryable`: a template refusing its props, the\n * contract refusing an emitted event, a provider rejecting the request) ends the step at\n * once instead of burning the retry budget.\n */\n do<T>(name: string, fn: () => Promise<T>): Promise<T> {\n return this.step.do(name, async () => {\n try {\n return await fn();\n } catch (error) {\n if (error instanceof Error && !isRetryable(error)) {\n throw new NonRetryableError(error.message, error.name);\n }\n throw error;\n }\n });\n }\n\n sleep(name: string, ms: number): Promise<void> {\n return this.step.sleep(name, ms);\n }\n\n sleepUntil(name: string, timestampMs: number): Promise<void> {\n return this.step.sleepUntil(name, timestampMs);\n }\n\n subscribe(name: string, events: readonly string[]): Promise<void> {\n // Idempotent in the store: the interpreter re-subscribes on every wait\n // attempt (one-shot-callback contract) and registrations persist here.\n return this.step.do(name, () =>\n this.store.subscribe(this.instanceId, this.userId, events, Date.now())\n );\n }\n\n unsubscribe(name: string): Promise<void> {\n return this.step.do(name, () => this.store.unsubscribe(this.instanceId));\n }\n\n async waitForWake(name: string, timeoutMs: number): Promise<'event' | 'timeout'> {\n const startedAt = Date.now();\n try {\n await this.step.waitForEvent(name, { type: WAKE_EVENT_TYPE, timeout: timeoutMs });\n return 'event';\n } catch (error) {\n /*\n * Only the deadline can tell a real timeout from a failure (see\n * wakeExpired). `startedAt` is not replay-stable, but it is read only\n * inside the step body below, and a step body runs on the live attempt\n * alone — a replay returns the persisted verdict without touching the\n * clock.\n */\n const timedOut = await this.step.do(`${name}:timed-out`, async () =>\n wakeExpired(startedAt, timeoutMs, Date.now())\n );\n if (timedOut) return 'timeout';\n throw error;\n }\n }\n}\n\n/**\n * The generic journey executor: one class runs every workflow, because IR is\n * data. It is loaded from the store by the contentHash pinned at entry —\n * content addressing makes it immutable, so reading it outside a step is\n * replay-safe.\n */\nexport class JourneyRunner extends WorkflowEntrypoint<JourneyEnv, JourneyParams> {\n /**\n * The data plane. D1 + KV from the env by default; an app on Postgres\n * overrides this (and `closeStore` if its client needs closing):\n *\n * protected override createStore() { return new PostgresJourneyStore(client(this.env)); }\n */\n protected createStore(): JourneyStore {\n return d1Store(this.env);\n }\n\n /** Called once `run` is over, success or not — release whatever `createStore` opened. */\n protected async closeStore(_store: JourneyStore): Promise<void> {}\n\n /**\n * Runtime options for this instance — typed configuration, resolved by the app (typically\n * `resolveRuntime(config.runtime, mode)` over its workflow.config.ts, with the mode coming from\n * an ENVIRONMENT var). The instance params are passed so the decision can also be per user:\n * a tester account in production can run in fast-forward while everyone else runs in real\n * time. Defaults: real time, quiet.\n *\n * protected override runtime({ userId }) {\n * return resolveRuntime(workflowConfig.runtime, isTester(userId) ? 'test' : this.env.ENVIRONMENT);\n * }\n */\n protected runtime(_params: JourneyParams): JourneyRuntimeOptions {\n return resolveRuntime(undefined, 'production');\n }\n\n /**\n * Message outlet; an app overrides this to plug in real channels (say\n * an EmailSender over a transport with a react-email renderer). By default it picks the webhook\n * or logging sender based on the environment.\n */\n protected createMessageSender(): MessageSender {\n return this.env.MESSAGE_WEBHOOK_URL\n ? new WebhookMessageSender(this.env.MESSAGE_WEBHOOK_URL)\n : new LogMessageSender();\n }\n\n /**\n * Custom-action registry; an app overrides this returning the same\n * `action(...)` objects its workflows reference (an ActionRef is a\n * RegisteredAction structurally) — the code plane of the dual-plane model:\n *\n * protected override actions() { return [syncCrm, issueCoupon]; }\n */\n protected actions(): readonly RegisteredAction[] {\n return [];\n }\n\n /**\n * The contract events this journey emits (`.sendEvent()`) are checked\n * against, exactly like ingest; journeyWorker passes its config's.\n */\n protected contract(): Contract | undefined {\n return undefined;\n }\n\n /**\n * Template props check before every send; an app overrides this with\n * `registryValidator({ emails, pushes })` (journeyWorker does it from its\n * registries). By default props go out as resolved.\n */\n protected createMessageValidator(): MessageValidator | undefined {\n return undefined;\n }\n\n /**\n * Reporting hooks: node arrivals, every copy sent, how each instance ended. The host stores\n * what it needs for its dashboards; by default nothing is reported.\n */\n protected createObserver(_store: JourneyStore): JourneyObserver | undefined {\n return undefined;\n }\n\n /** Override for a different version policy ('warn' by default) or a fully custom invoker (e.g. webhook degradation for hosted tenants). */\n protected createActionInvoker(): ActionInvoker {\n return new RegistryActionInvoker(this.actions());\n }\n\n /**\n * Everything one run is wired to. By default it is composed from the methods above; a host\n * that assembles the whole set itself (journeyWorker) overrides this one instead.\n */\n protected createRunParts(store: JourneyStore, params: JourneyParams): JourneyRunParts {\n return {\n store,\n env: this.env,\n runtime: this.runtime(params),\n messages: this.createMessageSender(),\n actions: this.createActionInvoker(),\n contract: this.contract(),\n validator: this.createMessageValidator(),\n observer: this.createObserver(store),\n };\n }\n\n async run(event: WorkflowEvent<JourneyParams>, step: WorkflowStep): Promise<JourneyOutcome> {\n const store = this.createStore();\n try {\n const { userId } = event.payload;\n return await executeJourney(\n {\n instanceId: event.instanceId,\n params: event.payload,\n // Trigger-event creation time: replay-stable, unlike Date.now() here\n enteredAtMs: event.timestamp.getTime(),\n step: new CfEngineStep(step, store, userId, event.instanceId),\n },\n this.createRunParts(store, event.payload)\n );\n } finally {\n await this.closeStore(store);\n }\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runner.d.cts","names":[],"sources":["../../src/cloudflare/runner.ts"],"mappings":";;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"runner.d.cts","names":[],"sources":["../../src/cloudflare/runner.ts"],"mappings":";;;;;;;;;;;;;;;qBAqGa,sBAAsB,mBAAmB,YAAY;;;;;;;YAOtD,eAAe;;YAKT,WAAW,QAAQ,eAAe;;;;;;;;;;;;YAaxC,QAAQ,SAAS,gBAAgB;;;;;;YASjC,uBAAuB;;;;;;;;YAavB,oBAAoB;;;;;YAQpB,YAAY;;;;;;YASZ,0BAA0B;;;;;YAQ1B,eAAe,QAAQ,eAAe;;YAKtC,uBAAuB;;;;;YAQvB,eAAe,OAAO,cAAc,QAAQ,gBAAgB;EAahE,IAAI,OAAO,cAAc,gBAAgB,MAAM,eAAe,QAAQ"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runner.d.mts","names":[],"sources":["../../src/cloudflare/runner.ts"],"mappings":";;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"runner.d.mts","names":[],"sources":["../../src/cloudflare/runner.ts"],"mappings":";;;;;;;;;;;;;;;qBAqGa,sBAAsB,mBAAmB,YAAY;;;;;;;YAOtD,eAAe;;YAKT,WAAW,QAAQ,eAAe;;;;;;;;;;;;YAaxC,QAAQ,SAAS,gBAAgB;;;;;;YASjC,uBAAuB;;;;;;;;YAavB,oBAAoB;;;;;YAQpB,YAAY;;;;;;YASZ,0BAA0B;;;;;YAQ1B,eAAe,QAAQ,eAAe;;YAKtC,uBAAuB;;;;;YAQvB,eAAe,OAAO,cAAc,QAAQ,gBAAgB;EAahE,IAAI,OAAO,cAAc,gBAAgB,MAAM,eAAe,QAAQ"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveRuntime } from "../runtime.mjs";
|
|
2
|
+
import { isRetryable } from "../engine/retry.mjs";
|
|
2
3
|
import { WAKE_EVENT_TYPE, wakeExpired } from "./bindings.mjs";
|
|
3
4
|
import { d1Store } from "./d1-env.mjs";
|
|
4
5
|
import { executeJourney } from "../host/execute.mjs";
|
|
@@ -14,8 +15,6 @@ import { NonRetryableError } from "cloudflare:workflows";
|
|
|
14
15
|
* waitForEvent, which signals a timeout by throwing — and so does every other
|
|
15
16
|
* failure in there, so only the clock can tell them apart (see wakeExpired).
|
|
16
17
|
*/
|
|
17
|
-
/** Refusals a retry would only repeat: a template's props, an emitted event's payload. */
|
|
18
|
-
const NON_RETRYABLE = /* @__PURE__ */ new Set(["TemplateValidationError", "ContractValidationError"]);
|
|
19
18
|
var CfEngineStep = class {
|
|
20
19
|
step;
|
|
21
20
|
store;
|
|
@@ -28,8 +27,8 @@ var CfEngineStep = class {
|
|
|
28
27
|
this.instanceId = instanceId;
|
|
29
28
|
}
|
|
30
29
|
/**
|
|
31
|
-
* A
|
|
32
|
-
*
|
|
30
|
+
* A failure a retry would only repeat (`isRetryable`: a template refusing its props, the
|
|
31
|
+
* contract refusing an emitted event, a provider rejecting the request) ends the step at
|
|
33
32
|
* once instead of burning the retry budget.
|
|
34
33
|
*/
|
|
35
34
|
do(name, fn) {
|
|
@@ -37,7 +36,7 @@ var CfEngineStep = class {
|
|
|
37
36
|
try {
|
|
38
37
|
return await fn();
|
|
39
38
|
} catch (error) {
|
|
40
|
-
if (error instanceof Error &&
|
|
39
|
+
if (error instanceof Error && !isRetryable(error)) throw new NonRetryableError(error.message, error.name);
|
|
41
40
|
throw error;
|
|
42
41
|
}
|
|
43
42
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runner.mjs","names":[],"sources":["../../src/cloudflare/runner.ts"],"sourcesContent":["import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers';\nimport { NonRetryableError } from 'cloudflare:workflows';\nimport type { Contract } from '../contract';\nimport { type RegisteredAction, RegistryActionInvoker } from '../engine/actions';\nimport type {\n ActionInvoker,\n EngineStep,\n JourneyObserver,\n JourneyOutcome,\n MessageSender,\n MessageValidator,\n} from '../engine/ports';\nimport { type JourneyRunParts, executeJourney } from '../host/execute';\nimport { LogMessageSender, WebhookMessageSender } from '../host/senders';\nimport { type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport { type JourneyEnv, type JourneyParams, WAKE_EVENT_TYPE, wakeExpired } from './bindings';\nimport { d1Store } from './d1-env';\n\n/**\n * Adapts CF's WorkflowStep to EngineStep.\n * subscribe/unsubscribe write the subscription table inside durable steps (the\n * router wakes instances from that table); waitForWake parks on CF's\n * waitForEvent, which signals a timeout by throwing — and so does every other\n * failure in there, so only the clock can tell them apart (see wakeExpired).\n */\n/** Refusals a retry would only repeat: a template's props, an emitted event's payload. */\nconst NON_RETRYABLE = new Set(['TemplateValidationError', 'ContractValidationError']);\n\nclass CfEngineStep implements EngineStep {\n constructor(\n private readonly step: WorkflowStep,\n private readonly store: JourneyStore,\n private readonly userId: string,\n private readonly instanceId: string\n ) {}\n\n /**\n * A template refusing its props, or the contract refusing an emitted event,\n * is deterministic — a retry hits the same refusal — so it ends the step at\n * once instead of burning the retry budget.\n */\n do<T>(name: string, fn: () => Promise<T>): Promise<T> {\n return this.step.do(name, async () => {\n try {\n return await fn();\n } catch (error) {\n if (error instanceof Error && NON_RETRYABLE.has(error.name)) {\n throw new NonRetryableError(error.message, error.name);\n }\n throw error;\n }\n });\n }\n\n sleep(name: string, ms: number): Promise<void> {\n return this.step.sleep(name, ms);\n }\n\n sleepUntil(name: string, timestampMs: number): Promise<void> {\n return this.step.sleepUntil(name, timestampMs);\n }\n\n subscribe(name: string, events: readonly string[]): Promise<void> {\n // Idempotent in the store: the interpreter re-subscribes on every wait\n // attempt (one-shot-callback contract) and registrations persist here.\n return this.step.do(name, () =>\n this.store.subscribe(this.instanceId, this.userId, events, Date.now())\n );\n }\n\n unsubscribe(name: string): Promise<void> {\n return this.step.do(name, () => this.store.unsubscribe(this.instanceId));\n }\n\n async waitForWake(name: string, timeoutMs: number): Promise<'event' | 'timeout'> {\n const startedAt = Date.now();\n try {\n await this.step.waitForEvent(name, { type: WAKE_EVENT_TYPE, timeout: timeoutMs });\n return 'event';\n } catch (error) {\n /*\n * Only the deadline can tell a real timeout from a failure (see\n * wakeExpired). `startedAt` is not replay-stable, but it is read only\n * inside the step body below, and a step body runs on the live attempt\n * alone — a replay returns the persisted verdict without touching the\n * clock.\n */\n const timedOut = await this.step.do(`${name}:timed-out`, async () =>\n wakeExpired(startedAt, timeoutMs, Date.now())\n );\n if (timedOut) return 'timeout';\n throw error;\n }\n }\n}\n\n/**\n * The generic journey executor: one class runs every workflow, because IR is\n * data. It is loaded from the store by the contentHash pinned at entry —\n * content addressing makes it immutable, so reading it outside a step is\n * replay-safe.\n */\nexport class JourneyRunner extends WorkflowEntrypoint<JourneyEnv, JourneyParams> {\n /**\n * The data plane. D1 + KV from the env by default; an app on Postgres\n * overrides this (and `closeStore` if its client needs closing):\n *\n * protected override createStore() { return new PostgresJourneyStore(client(this.env)); }\n */\n protected createStore(): JourneyStore {\n return d1Store(this.env);\n }\n\n /** Called once `run` is over, success or not — release whatever `createStore` opened. */\n protected async closeStore(_store: JourneyStore): Promise<void> {}\n\n /**\n * Runtime options for this instance — typed configuration, resolved by the app (typically\n * `resolveRuntime(config.runtime, mode)` over its workflow.config.ts, with the mode coming from\n * an ENVIRONMENT var). The instance params are passed so the decision can also be per user:\n * a tester account in production can run in fast-forward while everyone else runs in real\n * time. Defaults: real time, quiet.\n *\n * protected override runtime({ userId }) {\n * return resolveRuntime(workflowConfig.runtime, isTester(userId) ? 'test' : this.env.ENVIRONMENT);\n * }\n */\n protected runtime(_params: JourneyParams): JourneyRuntimeOptions {\n return resolveRuntime(undefined, 'production');\n }\n\n /**\n * Message outlet; an app overrides this to plug in real channels (say\n * an EmailSender over a transport with a react-email renderer). By default it picks the webhook\n * or logging sender based on the environment.\n */\n protected createMessageSender(): MessageSender {\n return this.env.MESSAGE_WEBHOOK_URL\n ? new WebhookMessageSender(this.env.MESSAGE_WEBHOOK_URL)\n : new LogMessageSender();\n }\n\n /**\n * Custom-action registry; an app overrides this returning the same\n * `action(...)` objects its workflows reference (an ActionRef is a\n * RegisteredAction structurally) — the code plane of the dual-plane model:\n *\n * protected override actions() { return [syncCrm, issueCoupon]; }\n */\n protected actions(): readonly RegisteredAction[] {\n return [];\n }\n\n /**\n * The contract events this journey emits (`.sendEvent()`) are checked\n * against, exactly like ingest; journeyWorker passes its config's.\n */\n protected contract(): Contract | undefined {\n return undefined;\n }\n\n /**\n * Template props check before every send; an app overrides this with\n * `registryValidator({ emails, pushes })` (journeyWorker does it from its\n * registries). By default props go out as resolved.\n */\n protected createMessageValidator(): MessageValidator | undefined {\n return undefined;\n }\n\n /**\n * Reporting hooks: node arrivals, every copy sent, how each instance ended. The host stores\n * what it needs for its dashboards; by default nothing is reported.\n */\n protected createObserver(_store: JourneyStore): JourneyObserver | undefined {\n return undefined;\n }\n\n /** Override for a different version policy ('warn' by default) or a fully custom invoker (e.g. webhook degradation for hosted tenants). */\n protected createActionInvoker(): ActionInvoker {\n return new RegistryActionInvoker(this.actions());\n }\n\n /**\n * Everything one run is wired to. By default it is composed from the methods above; a host\n * that assembles the whole set itself (journeyWorker) overrides this one instead.\n */\n protected createRunParts(store: JourneyStore, params: JourneyParams): JourneyRunParts {\n return {\n store,\n env: this.env,\n runtime: this.runtime(params),\n messages: this.createMessageSender(),\n actions: this.createActionInvoker(),\n contract: this.contract(),\n validator: this.createMessageValidator(),\n observer: this.createObserver(store),\n };\n }\n\n async run(event: WorkflowEvent<JourneyParams>, step: WorkflowStep): Promise<JourneyOutcome> {\n const store = this.createStore();\n try {\n const { userId } = event.payload;\n return await executeJourney(\n {\n instanceId: event.instanceId,\n params: event.payload,\n // Trigger-event creation time: replay-stable, unlike Date.now() here\n enteredAtMs: event.timestamp.getTime(),\n step: new CfEngineStep(step, store, userId, event.instanceId),\n },\n this.createRunParts(store, event.payload)\n );\n } finally {\n await this.closeStore(store);\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AA2BA,MAAM,gCAAgB,IAAI,IAAI,CAAC,2BAA2B,yBAAyB,CAAC;AAEpF,IAAM,eAAN,MAAyC;CAEpB;CACA;CACA;CACA;CAJnB,YACE,MACA,OACA,QACA,YACA;EAJiB,KAAA,OAAA;EACA,KAAA,QAAA;EACA,KAAA,SAAA;EACA,KAAA,aAAA;CAChB;;;;;;CAOH,GAAM,MAAc,IAAkC;EACpD,OAAO,KAAK,KAAK,GAAG,MAAM,YAAY;GACpC,IAAI;IACF,OAAO,MAAM,GAAG;GAClB,SAAS,OAAO;IACd,IAAI,iBAAiB,SAAS,cAAc,IAAI,MAAM,IAAI,GACxD,MAAM,IAAI,kBAAkB,MAAM,SAAS,MAAM,IAAI;IAEvD,MAAM;GACR;EACF,CAAC;CACH;CAEA,MAAM,MAAc,IAA2B;EAC7C,OAAO,KAAK,KAAK,MAAM,MAAM,EAAE;CACjC;CAEA,WAAW,MAAc,aAAoC;EAC3D,OAAO,KAAK,KAAK,WAAW,MAAM,WAAW;CAC/C;CAEA,UAAU,MAAc,QAA0C;EAGhE,OAAO,KAAK,KAAK,GAAG,YAClB,KAAK,MAAM,UAAU,KAAK,YAAY,KAAK,QAAQ,QAAQ,KAAK,IAAI,CAAC,CACvE;CACF;CAEA,YAAY,MAA6B;EACvC,OAAO,KAAK,KAAK,GAAG,YAAY,KAAK,MAAM,YAAY,KAAK,UAAU,CAAC;CACzE;CAEA,MAAM,YAAY,MAAc,WAAiD;EAC/E,MAAM,YAAY,KAAK,IAAI;EAC3B,IAAI;GACF,MAAM,KAAK,KAAK,aAAa,MAAM;IAAE,MAAM;IAAiB,SAAS;GAAU,CAAC;GAChF,OAAO;EACT,SAAS,OAAO;GAWd,IAAI,MAHmB,KAAK,KAAK,GAAG,GAAG,KAAK,aAAa,YACvD,YAAY,WAAW,WAAW,KAAK,IAAI,CAAC,CAC9C,GACc,OAAO;GACrB,MAAM;EACR;CACF;AACF;;;;;;;AAQA,IAAa,gBAAb,cAAmC,mBAA8C;;;;;;;CAO/E,cAAsC;EACpC,OAAO,QAAQ,KAAK,GAAG;CACzB;;CAGA,MAAgB,WAAW,QAAqC,CAAC;;;;;;;;;;;;CAajE,QAAkB,SAA+C;EAC/D,OAAO,eAAe,KAAA,GAAW,YAAY;CAC/C;;;;;;CAOA,sBAA+C;EAC7C,OAAO,KAAK,IAAI,sBACZ,IAAI,qBAAqB,KAAK,IAAI,mBAAmB,IACrD,IAAI,iBAAiB;CAC3B;;;;;;;;CASA,UAAiD;EAC/C,OAAO,CAAC;CACV;;;;;CAMA,WAA2C,CAE3C;;;;;;CAOA,yBAAiE,CAEjE;;;;;CAMA,eAAyB,QAAmD,CAE5E;;CAGA,sBAA+C;EAC7C,OAAO,IAAI,sBAAsB,KAAK,QAAQ,CAAC;CACjD;;;;;CAMA,eAAyB,OAAqB,QAAwC;EACpF,OAAO;GACL;GACA,KAAK,KAAK;GACV,SAAS,KAAK,QAAQ,MAAM;GAC5B,UAAU,KAAK,oBAAoB;GACnC,SAAS,KAAK,oBAAoB;GAClC,UAAU,KAAK,SAAS;GACxB,WAAW,KAAK,uBAAuB;GACvC,UAAU,KAAK,eAAe,KAAK;EACrC;CACF;CAEA,MAAM,IAAI,OAAqC,MAA6C;EAC1F,MAAM,QAAQ,KAAK,YAAY;EAC/B,IAAI;GACF,MAAM,EAAE,WAAW,MAAM;GACzB,OAAO,MAAM,eACX;IACE,YAAY,MAAM;IAClB,QAAQ,MAAM;IAEd,aAAa,MAAM,UAAU,QAAQ;IACrC,MAAM,IAAI,aAAa,MAAM,OAAO,QAAQ,MAAM,UAAU;GAC9D,GACA,KAAK,eAAe,OAAO,MAAM,OAAO,CAC1C;EACF,UAAU;GACR,MAAM,KAAK,WAAW,KAAK;EAC7B;CACF;AACF"}
|
|
1
|
+
{"version":3,"file":"runner.mjs","names":[],"sources":["../../src/cloudflare/runner.ts"],"sourcesContent":["import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers';\nimport { NonRetryableError } from 'cloudflare:workflows';\nimport type { Contract } from '../contract';\nimport { type RegisteredAction, RegistryActionInvoker } from '../engine/actions';\nimport type {\n ActionInvoker,\n EngineStep,\n JourneyObserver,\n JourneyOutcome,\n MessageSender,\n MessageValidator,\n} from '../engine/ports';\nimport { isRetryable } from '../engine/retry';\nimport { type JourneyRunParts, executeJourney } from '../host/execute';\nimport { LogMessageSender, WebhookMessageSender } from '../host/senders';\nimport { type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport { type JourneyEnv, type JourneyParams, WAKE_EVENT_TYPE, wakeExpired } from './bindings';\nimport { d1Store } from './d1-env';\n\n/**\n * Adapts CF's WorkflowStep to EngineStep.\n * subscribe/unsubscribe write the subscription table inside durable steps (the\n * router wakes instances from that table); waitForWake parks on CF's\n * waitForEvent, which signals a timeout by throwing — and so does every other\n * failure in there, so only the clock can tell them apart (see wakeExpired).\n */\nclass CfEngineStep implements EngineStep {\n constructor(\n private readonly step: WorkflowStep,\n private readonly store: JourneyStore,\n private readonly userId: string,\n private readonly instanceId: string\n ) {}\n\n /**\n * A failure a retry would only repeat (`isRetryable`: a template refusing its props, the\n * contract refusing an emitted event, a provider rejecting the request) ends the step at\n * once instead of burning the retry budget.\n */\n do<T>(name: string, fn: () => Promise<T>): Promise<T> {\n return this.step.do(name, async () => {\n try {\n return await fn();\n } catch (error) {\n if (error instanceof Error && !isRetryable(error)) {\n throw new NonRetryableError(error.message, error.name);\n }\n throw error;\n }\n });\n }\n\n sleep(name: string, ms: number): Promise<void> {\n return this.step.sleep(name, ms);\n }\n\n sleepUntil(name: string, timestampMs: number): Promise<void> {\n return this.step.sleepUntil(name, timestampMs);\n }\n\n subscribe(name: string, events: readonly string[]): Promise<void> {\n // Idempotent in the store: the interpreter re-subscribes on every wait\n // attempt (one-shot-callback contract) and registrations persist here.\n return this.step.do(name, () =>\n this.store.subscribe(this.instanceId, this.userId, events, Date.now())\n );\n }\n\n unsubscribe(name: string): Promise<void> {\n return this.step.do(name, () => this.store.unsubscribe(this.instanceId));\n }\n\n async waitForWake(name: string, timeoutMs: number): Promise<'event' | 'timeout'> {\n const startedAt = Date.now();\n try {\n await this.step.waitForEvent(name, { type: WAKE_EVENT_TYPE, timeout: timeoutMs });\n return 'event';\n } catch (error) {\n /*\n * Only the deadline can tell a real timeout from a failure (see\n * wakeExpired). `startedAt` is not replay-stable, but it is read only\n * inside the step body below, and a step body runs on the live attempt\n * alone — a replay returns the persisted verdict without touching the\n * clock.\n */\n const timedOut = await this.step.do(`${name}:timed-out`, async () =>\n wakeExpired(startedAt, timeoutMs, Date.now())\n );\n if (timedOut) return 'timeout';\n throw error;\n }\n }\n}\n\n/**\n * The generic journey executor: one class runs every workflow, because IR is\n * data. It is loaded from the store by the contentHash pinned at entry —\n * content addressing makes it immutable, so reading it outside a step is\n * replay-safe.\n */\nexport class JourneyRunner extends WorkflowEntrypoint<JourneyEnv, JourneyParams> {\n /**\n * The data plane. D1 + KV from the env by default; an app on Postgres\n * overrides this (and `closeStore` if its client needs closing):\n *\n * protected override createStore() { return new PostgresJourneyStore(client(this.env)); }\n */\n protected createStore(): JourneyStore {\n return d1Store(this.env);\n }\n\n /** Called once `run` is over, success or not — release whatever `createStore` opened. */\n protected async closeStore(_store: JourneyStore): Promise<void> {}\n\n /**\n * Runtime options for this instance — typed configuration, resolved by the app (typically\n * `resolveRuntime(config.runtime, mode)` over its workflow.config.ts, with the mode coming from\n * an ENVIRONMENT var). The instance params are passed so the decision can also be per user:\n * a tester account in production can run in fast-forward while everyone else runs in real\n * time. Defaults: real time, quiet.\n *\n * protected override runtime({ userId }) {\n * return resolveRuntime(workflowConfig.runtime, isTester(userId) ? 'test' : this.env.ENVIRONMENT);\n * }\n */\n protected runtime(_params: JourneyParams): JourneyRuntimeOptions {\n return resolveRuntime(undefined, 'production');\n }\n\n /**\n * Message outlet; an app overrides this to plug in real channels (say\n * an EmailSender over a transport with a react-email renderer). By default it picks the webhook\n * or logging sender based on the environment.\n */\n protected createMessageSender(): MessageSender {\n return this.env.MESSAGE_WEBHOOK_URL\n ? new WebhookMessageSender(this.env.MESSAGE_WEBHOOK_URL)\n : new LogMessageSender();\n }\n\n /**\n * Custom-action registry; an app overrides this returning the same\n * `action(...)` objects its workflows reference (an ActionRef is a\n * RegisteredAction structurally) — the code plane of the dual-plane model:\n *\n * protected override actions() { return [syncCrm, issueCoupon]; }\n */\n protected actions(): readonly RegisteredAction[] {\n return [];\n }\n\n /**\n * The contract events this journey emits (`.sendEvent()`) are checked\n * against, exactly like ingest; journeyWorker passes its config's.\n */\n protected contract(): Contract | undefined {\n return undefined;\n }\n\n /**\n * Template props check before every send; an app overrides this with\n * `registryValidator({ emails, pushes })` (journeyWorker does it from its\n * registries). By default props go out as resolved.\n */\n protected createMessageValidator(): MessageValidator | undefined {\n return undefined;\n }\n\n /**\n * Reporting hooks: node arrivals, every copy sent, how each instance ended. The host stores\n * what it needs for its dashboards; by default nothing is reported.\n */\n protected createObserver(_store: JourneyStore): JourneyObserver | undefined {\n return undefined;\n }\n\n /** Override for a different version policy ('warn' by default) or a fully custom invoker (e.g. webhook degradation for hosted tenants). */\n protected createActionInvoker(): ActionInvoker {\n return new RegistryActionInvoker(this.actions());\n }\n\n /**\n * Everything one run is wired to. By default it is composed from the methods above; a host\n * that assembles the whole set itself (journeyWorker) overrides this one instead.\n */\n protected createRunParts(store: JourneyStore, params: JourneyParams): JourneyRunParts {\n return {\n store,\n env: this.env,\n runtime: this.runtime(params),\n messages: this.createMessageSender(),\n actions: this.createActionInvoker(),\n contract: this.contract(),\n validator: this.createMessageValidator(),\n observer: this.createObserver(store),\n };\n }\n\n async run(event: WorkflowEvent<JourneyParams>, step: WorkflowStep): Promise<JourneyOutcome> {\n const store = this.createStore();\n try {\n const { userId } = event.payload;\n return await executeJourney(\n {\n instanceId: event.instanceId,\n params: event.payload,\n // Trigger-event creation time: replay-stable, unlike Date.now() here\n enteredAtMs: event.timestamp.getTime(),\n step: new CfEngineStep(step, store, userId, event.instanceId),\n },\n this.createRunParts(store, event.payload)\n );\n } finally {\n await this.closeStore(store);\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AA2BA,IAAM,eAAN,MAAyC;CAEpB;CACA;CACA;CACA;CAJnB,YACE,MACA,OACA,QACA,YACA;EAJiB,KAAA,OAAA;EACA,KAAA,QAAA;EACA,KAAA,SAAA;EACA,KAAA,aAAA;CAChB;;;;;;CAOH,GAAM,MAAc,IAAkC;EACpD,OAAO,KAAK,KAAK,GAAG,MAAM,YAAY;GACpC,IAAI;IACF,OAAO,MAAM,GAAG;GAClB,SAAS,OAAO;IACd,IAAI,iBAAiB,SAAS,CAAC,YAAY,KAAK,GAC9C,MAAM,IAAI,kBAAkB,MAAM,SAAS,MAAM,IAAI;IAEvD,MAAM;GACR;EACF,CAAC;CACH;CAEA,MAAM,MAAc,IAA2B;EAC7C,OAAO,KAAK,KAAK,MAAM,MAAM,EAAE;CACjC;CAEA,WAAW,MAAc,aAAoC;EAC3D,OAAO,KAAK,KAAK,WAAW,MAAM,WAAW;CAC/C;CAEA,UAAU,MAAc,QAA0C;EAGhE,OAAO,KAAK,KAAK,GAAG,YAClB,KAAK,MAAM,UAAU,KAAK,YAAY,KAAK,QAAQ,QAAQ,KAAK,IAAI,CAAC,CACvE;CACF;CAEA,YAAY,MAA6B;EACvC,OAAO,KAAK,KAAK,GAAG,YAAY,KAAK,MAAM,YAAY,KAAK,UAAU,CAAC;CACzE;CAEA,MAAM,YAAY,MAAc,WAAiD;EAC/E,MAAM,YAAY,KAAK,IAAI;EAC3B,IAAI;GACF,MAAM,KAAK,KAAK,aAAa,MAAM;IAAE,MAAM;IAAiB,SAAS;GAAU,CAAC;GAChF,OAAO;EACT,SAAS,OAAO;GAWd,IAAI,MAHmB,KAAK,KAAK,GAAG,GAAG,KAAK,aAAa,YACvD,YAAY,WAAW,WAAW,KAAK,IAAI,CAAC,CAC9C,GACc,OAAO;GACrB,MAAM;EACR;CACF;AACF;;;;;;;AAQA,IAAa,gBAAb,cAAmC,mBAA8C;;;;;;;CAO/E,cAAsC;EACpC,OAAO,QAAQ,KAAK,GAAG;CACzB;;CAGA,MAAgB,WAAW,QAAqC,CAAC;;;;;;;;;;;;CAajE,QAAkB,SAA+C;EAC/D,OAAO,eAAe,KAAA,GAAW,YAAY;CAC/C;;;;;;CAOA,sBAA+C;EAC7C,OAAO,KAAK,IAAI,sBACZ,IAAI,qBAAqB,KAAK,IAAI,mBAAmB,IACrD,IAAI,iBAAiB;CAC3B;;;;;;;;CASA,UAAiD;EAC/C,OAAO,CAAC;CACV;;;;;CAMA,WAA2C,CAE3C;;;;;;CAOA,yBAAiE,CAEjE;;;;;CAMA,eAAyB,QAAmD,CAE5E;;CAGA,sBAA+C;EAC7C,OAAO,IAAI,sBAAsB,KAAK,QAAQ,CAAC;CACjD;;;;;CAMA,eAAyB,OAAqB,QAAwC;EACpF,OAAO;GACL;GACA,KAAK,KAAK;GACV,SAAS,KAAK,QAAQ,MAAM;GAC5B,UAAU,KAAK,oBAAoB;GACnC,SAAS,KAAK,oBAAoB;GAClC,UAAU,KAAK,SAAS;GACxB,WAAW,KAAK,uBAAuB;GACvC,UAAU,KAAK,eAAe,KAAK;EACrC;CACF;CAEA,MAAM,IAAI,OAAqC,MAA6C;EAC1F,MAAM,QAAQ,KAAK,YAAY;EAC/B,IAAI;GACF,MAAM,EAAE,WAAW,MAAM;GACzB,OAAO,MAAM,eACX;IACE,YAAY,MAAM;IAClB,QAAQ,MAAM;IAEd,aAAa,MAAM,UAAU,QAAQ;IACrC,MAAM,IAAI,aAAa,MAAM,OAAO,QAAQ,MAAM,UAAU;GAC9D,GACA,KAAK,eAAe,OAAO,MAAM,OAAO,CAC1C;EACF,UAAU;GACR,MAAM,KAAK,WAAW,KAAK;EAC7B;CACF;AACF"}
|
package/dist/email-sender.cjs
CHANGED
|
@@ -17,16 +17,39 @@ var EmailSender = class {
|
|
|
17
17
|
const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props);
|
|
18
18
|
const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
|
|
19
19
|
await message.tracking?.commit?.();
|
|
20
|
+
const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);
|
|
20
21
|
await this.options.transport.send({
|
|
21
22
|
from,
|
|
22
23
|
to: message.recipient,
|
|
23
24
|
...replyTo === void 0 ? {} : { replyTo },
|
|
24
25
|
subject,
|
|
25
26
|
html: tracked,
|
|
27
|
+
...headers === void 0 ? {} : { headers },
|
|
26
28
|
idempotencyKey: message.idempotencyKey
|
|
27
29
|
});
|
|
28
30
|
}
|
|
29
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a
|
|
34
|
+
* mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:
|
|
35
|
+
*
|
|
36
|
+
* - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the
|
|
37
|
+
* header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.
|
|
38
|
+
* - Without one: the header names the email's own unsubscribe link, with no one-click claim —
|
|
39
|
+
* whether that page takes a POST is the app's.
|
|
40
|
+
* - An email without an unsubscribe link (a receipt) gets neither.
|
|
41
|
+
*
|
|
42
|
+
* Only an https URL is named: mail clients and Cloudflare Email refuse any other.
|
|
43
|
+
*/
|
|
44
|
+
function unsubscribeHeaders(unsubscribeUrl, oneClick) {
|
|
45
|
+
if (unsubscribeUrl === void 0) return void 0;
|
|
46
|
+
const https = (url) => url?.startsWith("https://") === true;
|
|
47
|
+
if (https(oneClick)) return {
|
|
48
|
+
"List-Unsubscribe": `<${oneClick}>`,
|
|
49
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
50
|
+
};
|
|
51
|
+
if (https(unsubscribeUrl)) return { "List-Unsubscribe": `<${unsubscribeUrl}>` };
|
|
52
|
+
}
|
|
30
53
|
const LINK = /(<a\b[^>]*?\shref\s*=\s*)(["'])(https?:\/\/[^"']+)\2/gi;
|
|
31
54
|
/**
|
|
32
55
|
* An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
|
|
@@ -58,5 +81,6 @@ function escapeAttribute(value) {
|
|
|
58
81
|
//#endregion
|
|
59
82
|
exports.EmailSender = EmailSender;
|
|
60
83
|
exports.trackEmail = trackEmail;
|
|
84
|
+
exports.unsubscribeHeaders = unsubscribeHeaders;
|
|
61
85
|
|
|
62
86
|
//# sourceMappingURL=email-sender.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email-sender.cjs","names":["isUnsubscribeLink"],"sources":["../src/email-sender.ts"],"sourcesContent":["import { type EmailAddress, type ResolvedEmail, isUnsubscribeLink } from './email';\nimport type { MessageSender, MessageTracking, OutboundMessage } from './engine/ports';\nimport type { ScalarIR } from './ir';\n\n/**\n * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,\n * applies its tracking and commits the tracked links — the same on every host — and hands the\n * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare\n * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.\n */\n\n/** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */\nexport interface RenderedEmail extends ResolvedEmail {\n subject: string;\n html: string;\n}\n\n/**\n * Template renderer, injected by the app — this package's core stays free of\n * any react-email dependency (`@shware/workflow/react-email` ships one over a\n * registry). The subject comes back final: it is sent exactly as written, from\n * the template's own sender, and the unsubscribe link is the one the HTML was\n * rendered with.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`EmailSender: no recipient for user '${message.userId}'`);\n }\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n await message.tracking?.commit?.();\n await this.options.transport.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n idempotencyKey: message.idempotencyKey,\n });\n }\n}\n\nconst LINK = /(<a\\b[^>]*?\\shref\\s*=\\s*)([\"'])(https?:\\/\\/[^\"']+)\\2/gi;\n\n/**\n * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through\n * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —\n * as the unsubscribe link when it is the one the email was rendered with, so what a click on it\n * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the\n * end when there is none.\n */\nexport function trackEmail(\n html: string,\n tracking: MessageTracking,\n unsubscribeUrl?: string\n): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(LINK, (_, start: string, quote: string, attribute: string) => {\n const url = unescapeAttribute(attribute);\n const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? 'unsubscribe' : 'link';\n return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;\n });\n }\n if (pixel !== undefined) {\n const image = `<img src=\"${escapeAttribute(pixel)}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;border:0;width:1px;height:1px\" />`;\n out = /<\\/body>/i.test(out) ? out.replace(/<\\/body>/i, `${image}</body>`) : `${out}${image}`;\n }\n return out;\n}\n\nfunction unescapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('"', '\"').replaceAll(''', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('\"', '"');\n}\n"],"mappings":";;;;;;;;
|
|
1
|
+
{"version":3,"file":"email-sender.cjs","names":["isUnsubscribeLink"],"sources":["../src/email-sender.ts"],"sourcesContent":["import { type EmailAddress, type ResolvedEmail, isUnsubscribeLink } from './email';\nimport type { MessageSender, MessageTracking, OutboundMessage } from './engine/ports';\nimport type { ScalarIR } from './ir';\n\n/**\n * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,\n * applies its tracking and commits the tracked links — the same on every host — and hands the\n * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare\n * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.\n */\n\n/** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */\nexport interface RenderedEmail extends ResolvedEmail {\n subject: string;\n html: string;\n}\n\n/**\n * Template renderer, injected by the app — this package's core stays free of\n * any react-email dependency (`@shware/workflow/react-email` ships one over a\n * registry). The subject comes back final: it is sent exactly as written, from\n * the template's own sender, and the unsubscribe link is the one the HTML was\n * rendered with.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an\n * email that has an unsubscribe link (see `unsubscribeHeaders`).\n */\n headers?: Readonly<Record<string, string>>;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`EmailSender: no recipient for user '${message.userId}'`);\n }\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n await message.tracking?.commit?.();\n const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);\n await this.options.transport.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n ...(headers === undefined ? {} : { headers }),\n idempotencyKey: message.idempotencyKey,\n });\n }\n}\n\n/**\n * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a\n * mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:\n *\n * - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the\n * header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.\n * - Without one: the header names the email's own unsubscribe link, with no one-click claim —\n * whether that page takes a POST is the app's.\n * - An email without an unsubscribe link (a receipt) gets neither.\n *\n * Only an https URL is named: mail clients and Cloudflare Email refuse any other.\n */\nexport function unsubscribeHeaders(\n unsubscribeUrl: string | undefined,\n oneClick: string | undefined\n): Record<string, string> | undefined {\n if (unsubscribeUrl === undefined) return undefined;\n const https = (url: string | undefined) => url?.startsWith('https://') === true;\n if (https(oneClick)) {\n return {\n 'List-Unsubscribe': `<${oneClick}>`,\n 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n };\n }\n if (https(unsubscribeUrl)) return { 'List-Unsubscribe': `<${unsubscribeUrl}>` };\n return undefined;\n}\n\nconst LINK = /(<a\\b[^>]*?\\shref\\s*=\\s*)([\"'])(https?:\\/\\/[^\"']+)\\2/gi;\n\n/**\n * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through\n * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —\n * as the unsubscribe link when it is the one the email was rendered with, so what a click on it\n * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the\n * end when there is none.\n */\nexport function trackEmail(\n html: string,\n tracking: MessageTracking,\n unsubscribeUrl?: string\n): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(LINK, (_, start: string, quote: string, attribute: string) => {\n const url = unescapeAttribute(attribute);\n const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? 'unsubscribe' : 'link';\n return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;\n });\n }\n if (pixel !== undefined) {\n const image = `<img src=\"${escapeAttribute(pixel)}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;border:0;width:1px;height:1px\" />`;\n out = /<\\/body>/i.test(out) ? out.replace(/<\\/body>/i, `${image}</body>`) : `${out}${image}`;\n }\n return out;\n}\n\nfunction unescapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('"', '\"').replaceAll(''', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('\"', '"');\n}\n"],"mappings":";;;;;;;;AAmEA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAyC;EAClD,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAEzE,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,uCAAuC,QAAQ,OAAO,EAAE;EAE1E,MAAM,EAAE,SAAS,MAAM,MAAM,SAAS,mBAAmB,MAAM,KAAK,QAAQ,OAC1E,QAAQ,UACR,QAAQ,KACV;EACA,MAAM,UACJ,QAAQ,aAAa,KAAA,IAAY,OAAO,WAAW,MAAM,QAAQ,UAAU,cAAc;EAC3F,MAAM,QAAQ,UAAU,SAAS;EACjC,MAAM,UAAU,mBAAmB,gBAAgB,QAAQ,UAAU,WAAW;EAChF,MAAM,KAAK,QAAQ,UAAU,KAAK;GAChC;GACA,IAAI,QAAQ;GACZ,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C;GACA,MAAM;GACN,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C,gBAAgB,QAAQ;EAC1B,CAAC;CACH;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,gBACA,UACoC;CACpC,IAAI,mBAAmB,KAAA,GAAW,OAAO,KAAA;CACzC,MAAM,SAAS,QAA4B,KAAK,WAAW,UAAU,MAAM;CAC3E,IAAI,MAAM,QAAQ,GAChB,OAAO;EACL,oBAAoB,IAAI,SAAS;EACjC,yBAAyB;CAC3B;CAEF,IAAI,MAAM,cAAc,GAAG,OAAO,EAAE,oBAAoB,IAAI,eAAe,GAAG;AAEhF;AAEA,MAAM,OAAO;;;;;;;;AASb,SAAgB,WACd,MACA,UACA,gBACQ;CACR,MAAM,EAAE,MAAM,UAAU;CACxB,IAAI,MAAM;CACV,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,QAAQ,OAAO,GAAG,OAAe,OAAe,cAAsB;EAC9E,MAAM,MAAM,kBAAkB,SAAS;EACvC,MAAM,UAAUA,cAAAA,kBAAkB,KAAK,cAAc,IAAI,gBAAgB;EACzE,OAAO,GAAG,QAAQ,QAAQ,gBAAgB,KAAK,KAAK,OAAO,CAAC,IAAI;CAClE,CAAC;CAEH,IAAI,UAAU,KAAA,GAAW;EACvB,MAAM,QAAQ,aAAa,gBAAgB,KAAK,EAAE;EAClD,MAAM,YAAY,KAAK,GAAG,IAAI,IAAI,QAAQ,aAAa,GAAG,MAAM,QAAQ,IAAI,GAAG,MAAM;CACvF;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,OAAuB;CAChD,OAAO,MAAM,WAAW,SAAS,GAAG,CAAC,CAAC,WAAW,UAAU,IAAG,CAAC,CAAC,WAAW,UAAU,GAAG;AAC1F;AAEA,SAAS,gBAAgB,OAAuB;CAC9C,OAAO,MAAM,WAAW,KAAK,OAAO,CAAC,CAAC,WAAW,MAAK,QAAQ;AAChE"}
|
package/dist/email-sender.d.cts
CHANGED
|
@@ -28,6 +28,11 @@ export interface EmailMessage {
|
|
|
28
28
|
replyTo?: EmailAddress;
|
|
29
29
|
subject: string;
|
|
30
30
|
html: string;
|
|
31
|
+
/**
|
|
32
|
+
* Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an
|
|
33
|
+
* email that has an unsubscribe link (see `unsubscribeHeaders`).
|
|
34
|
+
*/
|
|
35
|
+
headers?: Readonly<Record<string, string>>;
|
|
31
36
|
/**
|
|
32
37
|
* `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider
|
|
33
38
|
* that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —
|
|
@@ -58,6 +63,19 @@ export declare class EmailSender implements MessageSender {
|
|
|
58
63
|
constructor(options: EmailSenderOptions);
|
|
59
64
|
send(message: OutboundMessage): Promise<void>;
|
|
60
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a
|
|
68
|
+
* mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:
|
|
69
|
+
*
|
|
70
|
+
* - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the
|
|
71
|
+
* header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.
|
|
72
|
+
* - Without one: the header names the email's own unsubscribe link, with no one-click claim —
|
|
73
|
+
* whether that page takes a POST is the app's.
|
|
74
|
+
* - An email without an unsubscribe link (a receipt) gets neither.
|
|
75
|
+
*
|
|
76
|
+
* Only an https URL is named: mail clients and Cloudflare Email refuse any other.
|
|
77
|
+
*/
|
|
78
|
+
export declare function unsubscribeHeaders(unsubscribeUrl: string | undefined, oneClick: string | undefined): Record<string, string> | undefined;
|
|
61
79
|
/**
|
|
62
80
|
* An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
|
|
63
81
|
* `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email-sender.d.cts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;;iBAYiB,sBAAsB;EACrC;EACA;;;;;;;;;YAUU,iBACV,kBACA,OAAO,eAAe,0BACnB,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;;
|
|
1
|
+
{"version":3,"file":"email-sender.d.cts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;;iBAYiB,sBAAsB;EACrC;EACA;;;;;;;;;YAUU,iBACV,kBACA,OAAO,eAAe,0BACnB,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;EAKA,UAAU,SAAS;;;;;;EAMnB;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;;;;;;wBAuCxB,mBACd,oCACA,+BACC;;;;;;;;wBAsBa,WACd,cACA,UAAU,iBACV"}
|
package/dist/email-sender.d.mts
CHANGED
|
@@ -28,6 +28,11 @@ export interface EmailMessage {
|
|
|
28
28
|
replyTo?: EmailAddress;
|
|
29
29
|
subject: string;
|
|
30
30
|
html: string;
|
|
31
|
+
/**
|
|
32
|
+
* Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an
|
|
33
|
+
* email that has an unsubscribe link (see `unsubscribeHeaders`).
|
|
34
|
+
*/
|
|
35
|
+
headers?: Readonly<Record<string, string>>;
|
|
31
36
|
/**
|
|
32
37
|
* `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider
|
|
33
38
|
* that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —
|
|
@@ -58,6 +63,19 @@ export declare class EmailSender implements MessageSender {
|
|
|
58
63
|
constructor(options: EmailSenderOptions);
|
|
59
64
|
send(message: OutboundMessage): Promise<void>;
|
|
60
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a
|
|
68
|
+
* mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:
|
|
69
|
+
*
|
|
70
|
+
* - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the
|
|
71
|
+
* header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.
|
|
72
|
+
* - Without one: the header names the email's own unsubscribe link, with no one-click claim —
|
|
73
|
+
* whether that page takes a POST is the app's.
|
|
74
|
+
* - An email without an unsubscribe link (a receipt) gets neither.
|
|
75
|
+
*
|
|
76
|
+
* Only an https URL is named: mail clients and Cloudflare Email refuse any other.
|
|
77
|
+
*/
|
|
78
|
+
export declare function unsubscribeHeaders(unsubscribeUrl: string | undefined, oneClick: string | undefined): Record<string, string> | undefined;
|
|
61
79
|
/**
|
|
62
80
|
* An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
|
|
63
81
|
* `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email-sender.d.mts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;;iBAYiB,sBAAsB;EACrC;EACA;;;;;;;;;YAUU,iBACV,kBACA,OAAO,eAAe,0BACnB,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;;
|
|
1
|
+
{"version":3,"file":"email-sender.d.mts","names":[],"sources":["../src/email-sender.ts"],"mappings":";;;;;;;;;;;iBAYiB,sBAAsB;EACrC;EACA;;;;;;;;;YAUU,iBACV,kBACA,OAAO,eAAe,0BACnB,QAAQ;;iBAGI;EACf,MAAM;EACN;EACA,UAAU;EACV;EACA;;;;;EAKA,UAAU,SAAS;;;;;;EAMnB;;;;;;iBAOe;EACf,KAAK,SAAS,eAAe;IAAU;;;iBAGxB;EACf,WAAW;EACX,QAAQ;;;;;;;qBAQG,uBAAuB;mBACL;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB;;;;;;;;;;;;;;wBAuCxB,mBACd,oCACA,+BACC;;;;;;;;wBAsBa,WACd,cACA,UAAU,iBACV"}
|
package/dist/email-sender.mjs
CHANGED
|
@@ -16,16 +16,39 @@ var EmailSender = class {
|
|
|
16
16
|
const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(message.template, message.props);
|
|
17
17
|
const tracked = message.tracking === void 0 ? html : trackEmail(html, message.tracking, unsubscribeUrl);
|
|
18
18
|
await message.tracking?.commit?.();
|
|
19
|
+
const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);
|
|
19
20
|
await this.options.transport.send({
|
|
20
21
|
from,
|
|
21
22
|
to: message.recipient,
|
|
22
23
|
...replyTo === void 0 ? {} : { replyTo },
|
|
23
24
|
subject,
|
|
24
25
|
html: tracked,
|
|
26
|
+
...headers === void 0 ? {} : { headers },
|
|
25
27
|
idempotencyKey: message.idempotencyKey
|
|
26
28
|
});
|
|
27
29
|
}
|
|
28
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a
|
|
33
|
+
* mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:
|
|
34
|
+
*
|
|
35
|
+
* - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the
|
|
36
|
+
* header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.
|
|
37
|
+
* - Without one: the header names the email's own unsubscribe link, with no one-click claim —
|
|
38
|
+
* whether that page takes a POST is the app's.
|
|
39
|
+
* - An email without an unsubscribe link (a receipt) gets neither.
|
|
40
|
+
*
|
|
41
|
+
* Only an https URL is named: mail clients and Cloudflare Email refuse any other.
|
|
42
|
+
*/
|
|
43
|
+
function unsubscribeHeaders(unsubscribeUrl, oneClick) {
|
|
44
|
+
if (unsubscribeUrl === void 0) return void 0;
|
|
45
|
+
const https = (url) => url?.startsWith("https://") === true;
|
|
46
|
+
if (https(oneClick)) return {
|
|
47
|
+
"List-Unsubscribe": `<${oneClick}>`,
|
|
48
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
|
|
49
|
+
};
|
|
50
|
+
if (https(unsubscribeUrl)) return { "List-Unsubscribe": `<${unsubscribeUrl}>` };
|
|
51
|
+
}
|
|
29
52
|
const LINK = /(<a\b[^>]*?\shref\s*=\s*)(["'])(https?:\/\/[^"']+)\2/gi;
|
|
30
53
|
/**
|
|
31
54
|
* An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through
|
|
@@ -55,6 +78,6 @@ function escapeAttribute(value) {
|
|
|
55
78
|
return value.replaceAll("&", "&").replaceAll("\"", """);
|
|
56
79
|
}
|
|
57
80
|
//#endregion
|
|
58
|
-
export { EmailSender, trackEmail };
|
|
81
|
+
export { EmailSender, trackEmail, unsubscribeHeaders };
|
|
59
82
|
|
|
60
83
|
//# sourceMappingURL=email-sender.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"email-sender.mjs","names":[],"sources":["../src/email-sender.ts"],"sourcesContent":["import { type EmailAddress, type ResolvedEmail, isUnsubscribeLink } from './email';\nimport type { MessageSender, MessageTracking, OutboundMessage } from './engine/ports';\nimport type { ScalarIR } from './ir';\n\n/**\n * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,\n * applies its tracking and commits the tracked links — the same on every host — and hands the\n * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare\n * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.\n */\n\n/** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */\nexport interface RenderedEmail extends ResolvedEmail {\n subject: string;\n html: string;\n}\n\n/**\n * Template renderer, injected by the app — this package's core stays free of\n * any react-email dependency (`@shware/workflow/react-email` ships one over a\n * registry). The subject comes back final: it is sent exactly as written, from\n * the template's own sender, and the unsubscribe link is the one the HTML was\n * rendered with.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`EmailSender: no recipient for user '${message.userId}'`);\n }\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n await message.tracking?.commit?.();\n await this.options.transport.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n idempotencyKey: message.idempotencyKey,\n });\n }\n}\n\nconst LINK = /(<a\\b[^>]*?\\shref\\s*=\\s*)([\"'])(https?:\\/\\/[^\"']+)\\2/gi;\n\n/**\n * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through\n * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —\n * as the unsubscribe link when it is the one the email was rendered with, so what a click on it\n * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the\n * end when there is none.\n */\nexport function trackEmail(\n html: string,\n tracking: MessageTracking,\n unsubscribeUrl?: string\n): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(LINK, (_, start: string, quote: string, attribute: string) => {\n const url = unescapeAttribute(attribute);\n const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? 'unsubscribe' : 'link';\n return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;\n });\n }\n if (pixel !== undefined) {\n const image = `<img src=\"${escapeAttribute(pixel)}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;border:0;width:1px;height:1px\" />`;\n out = /<\\/body>/i.test(out) ? out.replace(/<\\/body>/i, `${image}</body>`) : `${out}${image}`;\n }\n return out;\n}\n\nfunction unescapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('"', '\"').replaceAll(''', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('\"', '"');\n}\n"],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"email-sender.mjs","names":[],"sources":["../src/email-sender.ts"],"sourcesContent":["import { type EmailAddress, type ResolvedEmail, isUnsubscribeLink } from './email';\nimport type { MessageSender, MessageTracking, OutboundMessage } from './engine/ports';\nimport type { ScalarIR } from './ir';\n\n/**\n * The email channel, split where the runtime stops mattering: `EmailSender` renders a copy,\n * applies its tracking and commits the tracked links — the same on every host — and hands the\n * finished email to an `EmailTransport`, the only part that knows a provider (SES, Cloudflare\n * Email, ...). Nothing here imports a platform module, so a Worker and a Lambda share it.\n */\n\n/** A rendered email: the template's resolved metadata (`resolveEmail`), its subject and its HTML. */\nexport interface RenderedEmail extends ResolvedEmail {\n subject: string;\n html: string;\n}\n\n/**\n * Template renderer, injected by the app — this package's core stays free of\n * any react-email dependency (`@shware/workflow/react-email` ships one over a\n * registry). The subject comes back final: it is sent exactly as written, from\n * the template's own sender, and the unsubscribe link is the one the HTML was\n * rendered with.\n */\nexport type EmailRenderer = (\n template: string,\n props: Record<string, ScalarIR | undefined>\n) => Promise<RenderedEmail>;\n\n/** A finished email, as a transport takes it: rendered, tracked, addressed. */\nexport interface EmailMessage {\n from: EmailAddress;\n to: string;\n replyTo?: EmailAddress;\n subject: string;\n html: string;\n /**\n * Extra headers, already formatted: `List-Unsubscribe` and `List-Unsubscribe-Post` for an\n * email that has an unsubscribe link (see `unsubscribeHeaders`).\n */\n headers?: Readonly<Record<string, string>>;\n /**\n * `${instanceId}:${nodeId}`: the copy's identity, stable across retries and replays. A provider\n * that de-duplicates on a key receives it; one that cannot (SES) still sends at least once —\n * the window between the provider accepting the email and the step's checkpoint stays open.\n */\n idempotencyKey: string;\n}\n\n/**\n * One email provider. Throwing hands the retry to the durable step; resolving means the provider\n * accepted the email (not that it was delivered).\n */\nexport interface EmailTransport {\n send(message: EmailMessage): Promise<{ providerMessageId?: string }>;\n}\n\nexport interface EmailSenderOptions {\n transport: EmailTransport;\n render: EmailRenderer;\n}\n\n/**\n * The email channel over any transport: render → tracking applied to the HTML → the tracked\n * links committed → the transport. The links resolve before the email exists anywhere a\n * scanner could follow them.\n */\nexport class EmailSender implements MessageSender {\n constructor(private readonly options: EmailSenderOptions) {}\n\n async send(message: OutboundMessage): Promise<void> {\n if (message.channel !== 'email') {\n throw new Error(`EmailSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`EmailSender: no recipient for user '${message.userId}'`);\n }\n const { subject, html, from, replyTo, unsubscribeUrl } = await this.options.render(\n message.template,\n message.props\n );\n const tracked =\n message.tracking === undefined ? html : trackEmail(html, message.tracking, unsubscribeUrl);\n await message.tracking?.commit?.();\n const headers = unsubscribeHeaders(unsubscribeUrl, message.tracking?.unsubscribe);\n await this.options.transport.send({\n from,\n to: message.recipient,\n ...(replyTo === undefined ? {} : { replyTo }),\n subject,\n html: tracked,\n ...(headers === undefined ? {} : { headers }),\n idempotencyKey: message.idempotencyKey,\n });\n }\n}\n\n/**\n * The list-unsubscribe headers of an email with an unsubscribe link (RFC 2369, RFC 8058), so a\n * mail client's own unsubscribe button works — Gmail and Yahoo require one-click for bulk mail:\n *\n * - With a one-click endpoint (`MessageTracking.unsubscribe`, which reporting provides): the\n * header names it, and `List-Unsubscribe-Post` says a POST there unsubscribes at once.\n * - Without one: the header names the email's own unsubscribe link, with no one-click claim —\n * whether that page takes a POST is the app's.\n * - An email without an unsubscribe link (a receipt) gets neither.\n *\n * Only an https URL is named: mail clients and Cloudflare Email refuse any other.\n */\nexport function unsubscribeHeaders(\n unsubscribeUrl: string | undefined,\n oneClick: string | undefined\n): Record<string, string> | undefined {\n if (unsubscribeUrl === undefined) return undefined;\n const https = (url: string | undefined) => url?.startsWith('https://') === true;\n if (https(oneClick)) {\n return {\n 'List-Unsubscribe': `<${oneClick}>`,\n 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',\n };\n }\n if (https(unsubscribeUrl)) return { 'List-Unsubscribe': `<${unsubscribeUrl}>` };\n return undefined;\n}\n\nconst LINK = /(<a\\b[^>]*?\\shref\\s*=\\s*)([\"'])(https?:\\/\\/[^\"']+)\\2/gi;\n\n/**\n * An email's tracking applied to its rendered HTML: every `http(s)` anchor routed through\n * `link` (the original URL decoded from its attribute escaping, the tracking URL escaped back) —\n * as the unsubscribe link when it is the one the email was rendered with, so what a click on it\n * means is fixed when the email is sent — and the open pixel appended before `</body>`, or at the\n * end when there is none.\n */\nexport function trackEmail(\n html: string,\n tracking: MessageTracking,\n unsubscribeUrl?: string\n): string {\n const { link, pixel } = tracking;\n let out = html;\n if (link !== undefined) {\n out = out.replace(LINK, (_, start: string, quote: string, attribute: string) => {\n const url = unescapeAttribute(attribute);\n const purpose = isUnsubscribeLink(url, unsubscribeUrl) ? 'unsubscribe' : 'link';\n return `${start}${quote}${escapeAttribute(link(url, purpose))}${quote}`;\n });\n }\n if (pixel !== undefined) {\n const image = `<img src=\"${escapeAttribute(pixel)}\" width=\"1\" height=\"1\" alt=\"\" style=\"display:block;border:0;width:1px;height:1px\" />`;\n out = /<\\/body>/i.test(out) ? out.replace(/<\\/body>/i, `${image}</body>`) : `${out}${image}`;\n }\n return out;\n}\n\nfunction unescapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('"', '\"').replaceAll(''', \"'\");\n}\n\nfunction escapeAttribute(value: string): string {\n return value.replaceAll('&', '&').replaceAll('\"', '"');\n}\n"],"mappings":";;;;;;;AAmEA,IAAa,cAAb,MAAkD;CACnB;CAA7B,YAAY,SAA8C;EAA7B,KAAA,UAAA;CAA8B;CAE3D,MAAM,KAAK,SAAyC;EAClD,IAAI,QAAQ,YAAY,SACtB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,QAAQ,EAAE;EAEzE,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,IAAI,MAAM,uCAAuC,QAAQ,OAAO,EAAE;EAE1E,MAAM,EAAE,SAAS,MAAM,MAAM,SAAS,mBAAmB,MAAM,KAAK,QAAQ,OAC1E,QAAQ,UACR,QAAQ,KACV;EACA,MAAM,UACJ,QAAQ,aAAa,KAAA,IAAY,OAAO,WAAW,MAAM,QAAQ,UAAU,cAAc;EAC3F,MAAM,QAAQ,UAAU,SAAS;EACjC,MAAM,UAAU,mBAAmB,gBAAgB,QAAQ,UAAU,WAAW;EAChF,MAAM,KAAK,QAAQ,UAAU,KAAK;GAChC;GACA,IAAI,QAAQ;GACZ,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C;GACA,MAAM;GACN,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;GAC3C,gBAAgB,QAAQ;EAC1B,CAAC;CACH;AACF;;;;;;;;;;;;;AAcA,SAAgB,mBACd,gBACA,UACoC;CACpC,IAAI,mBAAmB,KAAA,GAAW,OAAO,KAAA;CACzC,MAAM,SAAS,QAA4B,KAAK,WAAW,UAAU,MAAM;CAC3E,IAAI,MAAM,QAAQ,GAChB,OAAO;EACL,oBAAoB,IAAI,SAAS;EACjC,yBAAyB;CAC3B;CAEF,IAAI,MAAM,cAAc,GAAG,OAAO,EAAE,oBAAoB,IAAI,eAAe,GAAG;AAEhF;AAEA,MAAM,OAAO;;;;;;;;AASb,SAAgB,WACd,MACA,UACA,gBACQ;CACR,MAAM,EAAE,MAAM,UAAU;CACxB,IAAI,MAAM;CACV,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,QAAQ,OAAO,GAAG,OAAe,OAAe,cAAsB;EAC9E,MAAM,MAAM,kBAAkB,SAAS;EACvC,MAAM,UAAU,kBAAkB,KAAK,cAAc,IAAI,gBAAgB;EACzE,OAAO,GAAG,QAAQ,QAAQ,gBAAgB,KAAK,KAAK,OAAO,CAAC,IAAI;CAClE,CAAC;CAEH,IAAI,UAAU,KAAA,GAAW;EACvB,MAAM,QAAQ,aAAa,gBAAgB,KAAK,EAAE;EAClD,MAAM,YAAY,KAAK,GAAG,IAAI,IAAI,QAAQ,aAAa,GAAG,MAAM,QAAQ,IAAI,GAAG,MAAM;CACvF;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,OAAuB;CAChD,OAAO,MAAM,WAAW,SAAS,GAAG,CAAC,CAAC,WAAW,UAAU,IAAG,CAAC,CAAC,WAAW,UAAU,GAAG;AAC1F;AAEA,SAAS,gBAAgB,OAAuB;CAC9C,OAAO,MAAM,WAAW,KAAK,OAAO,CAAC,CAAC,WAAW,MAAK,QAAQ;AAChE"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/engine/duration.ts
|
|
3
|
+
/**
|
|
4
|
+
* The longest an instance of a workflow can live, from its IR: every delay at its longest, every
|
|
5
|
+
* wait to its timeout, a time window as a full week (the furthest the next allowed slot can be),
|
|
6
|
+
* the longest arm of a branch or cohort — and at least the goal's window, which keeps an instance
|
|
7
|
+
* watching after its last step. Message, action and event steps count as instant. A runtime that
|
|
8
|
+
* caps an instance's lifetime (a Lambda durable execution's `ExecutionTimeout`) checks its
|
|
9
|
+
* workflows against it, rather than finding out when the platform stops one midway.
|
|
10
|
+
*/
|
|
11
|
+
const WEEK_MS = 6048e5;
|
|
12
|
+
function flowMs(nodes) {
|
|
13
|
+
let total = 0;
|
|
14
|
+
for (const node of nodes) total += nodeMs(node);
|
|
15
|
+
return total;
|
|
16
|
+
}
|
|
17
|
+
function nodeMs(node) {
|
|
18
|
+
switch (node.type) {
|
|
19
|
+
case "delay": return node.duration.ms;
|
|
20
|
+
case "random_delay": return Math.max(node.min.ms, node.max.ms);
|
|
21
|
+
case "time_window": return WEEK_MS;
|
|
22
|
+
case "wait_until": return node.timeout.ms + (Array.isArray(node.onTimeout) ? flowMs(node.onTimeout) : 0);
|
|
23
|
+
case "branch": return Math.max(flowMs(node.otherwise ?? []), ...node.cases.map((branch) => flowMs(branch.flow)));
|
|
24
|
+
case "cohort": return Math.max(0, ...node.arms.map((arm) => flowMs(arm.flow)));
|
|
25
|
+
default: return 0;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function longestJourneyMs(ir) {
|
|
29
|
+
return Math.max(flowMs(ir.flow), ir.goal?.within?.ms ?? 0);
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
exports.longestJourneyMs = longestJourneyMs;
|
|
33
|
+
|
|
34
|
+
//# sourceMappingURL=duration.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"duration.cjs","names":[],"sources":["../../src/engine/duration.ts"],"sourcesContent":["import type { NodeIR, WorkflowIR } from '../ir';\n\n/**\n * The longest an instance of a workflow can live, from its IR: every delay at its longest, every\n * wait to its timeout, a time window as a full week (the furthest the next allowed slot can be),\n * the longest arm of a branch or cohort — and at least the goal's window, which keeps an instance\n * watching after its last step. Message, action and event steps count as instant. A runtime that\n * caps an instance's lifetime (a Lambda durable execution's `ExecutionTimeout`) checks its\n * workflows against it, rather than finding out when the platform stops one midway.\n */\n\nconst WEEK_MS = 7 * 24 * 3_600_000;\n\nfunction flowMs(nodes: readonly NodeIR[]): number {\n let total = 0;\n for (const node of nodes) total += nodeMs(node);\n return total;\n}\n\nfunction nodeMs(node: NodeIR): number {\n switch (node.type) {\n case 'delay':\n return node.duration.ms;\n case 'random_delay':\n return Math.max(node.min.ms, node.max.ms);\n case 'time_window':\n return WEEK_MS;\n case 'wait_until':\n return node.timeout.ms + (Array.isArray(node.onTimeout) ? flowMs(node.onTimeout) : 0);\n case 'branch':\n return Math.max(\n flowMs(node.otherwise ?? []),\n ...node.cases.map((branch) => flowMs(branch.flow))\n );\n case 'cohort':\n return Math.max(0, ...node.arms.map((arm) => flowMs(arm.flow)));\n default:\n return 0;\n }\n}\n\nexport function longestJourneyMs(ir: Pick<WorkflowIR, 'flow' | 'goal'>): number {\n return Math.max(flowMs(ir.flow), ir.goal?.within?.ms ?? 0);\n}\n"],"mappings":";;;;;;;;;;AAWA,MAAM,UAAU;AAEhB,SAAS,OAAO,OAAkC;CAChD,IAAI,QAAQ;CACZ,KAAK,MAAM,QAAQ,OAAO,SAAS,OAAO,IAAI;CAC9C,OAAO;AACT;AAEA,SAAS,OAAO,MAAsB;CACpC,QAAQ,KAAK,MAAb;EACE,KAAK,SACH,OAAO,KAAK,SAAS;EACvB,KAAK,gBACH,OAAO,KAAK,IAAI,KAAK,IAAI,IAAI,KAAK,IAAI,EAAE;EAC1C,KAAK,eACH,OAAO;EACT,KAAK,cACH,OAAO,KAAK,QAAQ,MAAM,MAAM,QAAQ,KAAK,SAAS,IAAI,OAAO,KAAK,SAAS,IAAI;EACrF,KAAK,UACH,OAAO,KAAK,IACV,OAAO,KAAK,aAAa,CAAC,CAAC,GAC3B,GAAG,KAAK,MAAM,KAAK,WAAW,OAAO,OAAO,IAAI,CAAC,CACnD;EACF,KAAK,UACH,OAAO,KAAK,IAAI,GAAG,GAAG,KAAK,KAAK,KAAK,QAAQ,OAAO,IAAI,IAAI,CAAC,CAAC;EAChE,SACE,OAAO;CACX;AACF;AAEA,SAAgB,iBAAiB,IAA+C;CAC9E,OAAO,KAAK,IAAI,OAAO,GAAG,IAAI,GAAG,GAAG,MAAM,QAAQ,MAAM,CAAC;AAC3D"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"duration.d.cts","names":[],"sources":["../../src/engine/duration.ts"],"mappings":";;wBAyCgB,iBAAiB,IAAI,KAAK"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"duration.d.mts","names":[],"sources":["../../src/engine/duration.ts"],"mappings":";;wBAyCgB,iBAAiB,IAAI,KAAK"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//#region src/engine/duration.ts
|
|
2
|
+
/**
|
|
3
|
+
* The longest an instance of a workflow can live, from its IR: every delay at its longest, every
|
|
4
|
+
* wait to its timeout, a time window as a full week (the furthest the next allowed slot can be),
|
|
5
|
+
* the longest arm of a branch or cohort — and at least the goal's window, which keeps an instance
|
|
6
|
+
* watching after its last step. Message, action and event steps count as instant. A runtime that
|
|
7
|
+
* caps an instance's lifetime (a Lambda durable execution's `ExecutionTimeout`) checks its
|
|
8
|
+
* workflows against it, rather than finding out when the platform stops one midway.
|
|
9
|
+
*/
|
|
10
|
+
const WEEK_MS = 6048e5;
|
|
11
|
+
function flowMs(nodes) {
|
|
12
|
+
let total = 0;
|
|
13
|
+
for (const node of nodes) total += nodeMs(node);
|
|
14
|
+
return total;
|
|
15
|
+
}
|
|
16
|
+
function nodeMs(node) {
|
|
17
|
+
switch (node.type) {
|
|
18
|
+
case "delay": return node.duration.ms;
|
|
19
|
+
case "random_delay": return Math.max(node.min.ms, node.max.ms);
|
|
20
|
+
case "time_window": return WEEK_MS;
|
|
21
|
+
case "wait_until": return node.timeout.ms + (Array.isArray(node.onTimeout) ? flowMs(node.onTimeout) : 0);
|
|
22
|
+
case "branch": return Math.max(flowMs(node.otherwise ?? []), ...node.cases.map((branch) => flowMs(branch.flow)));
|
|
23
|
+
case "cohort": return Math.max(0, ...node.arms.map((arm) => flowMs(arm.flow)));
|
|
24
|
+
default: return 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function longestJourneyMs(ir) {
|
|
28
|
+
return Math.max(flowMs(ir.flow), ir.goal?.within?.ms ?? 0);
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { longestJourneyMs };
|
|
32
|
+
|
|
33
|
+
//# sourceMappingURL=duration.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"duration.mjs","names":[],"sources":["../../src/engine/duration.ts"],"sourcesContent":["import type { NodeIR, WorkflowIR } from '../ir';\n\n/**\n * The longest an instance of a workflow can live, from its IR: every delay at its longest, every\n * wait to its timeout, a time window as a full week (the furthest the next allowed slot can be),\n * the longest arm of a branch or cohort — and at least the goal's window, which keeps an instance\n * watching after its last step. Message, action and event steps count as instant. A runtime that\n * caps an instance's lifetime (a Lambda durable execution's `ExecutionTimeout`) checks its\n * workflows against it, rather than finding out when the platform stops one midway.\n */\n\nconst WEEK_MS = 7 * 24 * 3_600_000;\n\nfunction flowMs(nodes: readonly NodeIR[]): number {\n let total = 0;\n for (const node of nodes) total += nodeMs(node);\n return total;\n}\n\nfunction nodeMs(node: NodeIR): number {\n switch (node.type) {\n case 'delay':\n return node.duration.ms;\n case 'random_delay':\n return Math.max(node.min.ms, node.max.ms);\n case 'time_window':\n return WEEK_MS;\n case 'wait_until':\n return node.timeout.ms + (Array.isArray(node.onTimeout) ? flowMs(node.onTimeout) : 0);\n case 'branch':\n return Math.max(\n flowMs(node.otherwise ?? []),\n ...node.cases.map((branch) => flowMs(branch.flow))\n );\n case 'cohort':\n return Math.max(0, ...node.arms.map((arm) => flowMs(arm.flow)));\n default:\n return 0;\n }\n}\n\nexport function longestJourneyMs(ir: Pick<WorkflowIR, 'flow' | 'goal'>): number {\n return Math.max(flowMs(ir.flow), ir.goal?.within?.ms ?? 0);\n}\n"],"mappings":";;;;;;;;;AAWA,MAAM,UAAU;AAEhB,SAAS,OAAO,OAAkC;CAChD,IAAI,QAAQ;CACZ,KAAK,MAAM,QAAQ,OAAO,SAAS,OAAO,IAAI;CAC9C,OAAO;AACT;AAEA,SAAS,OAAO,MAAsB;CACpC,QAAQ,KAAK,MAAb;EACE,KAAK,SACH,OAAO,KAAK,SAAS;EACvB,KAAK,gBACH,OAAO,KAAK,IAAI,KAAK,IAAI,IAAI,KAAK,IAAI,EAAE;EAC1C,KAAK,eACH,OAAO;EACT,KAAK,cACH,OAAO,KAAK,QAAQ,MAAM,MAAM,QAAQ,KAAK,SAAS,IAAI,OAAO,KAAK,SAAS,IAAI;EACrF,KAAK,UACH,OAAO,KAAK,IACV,OAAO,KAAK,aAAa,CAAC,CAAC,GAC3B,GAAG,KAAK,MAAM,KAAK,WAAW,OAAO,OAAO,IAAI,CAAC,CACnD;EACF,KAAK,UACH,OAAO,KAAK,IAAI,GAAG,GAAG,KAAK,KAAK,KAAK,QAAQ,OAAO,IAAI,IAAI,CAAC,CAAC;EAChE,SACE,OAAO;CACX;AACF;AAEA,SAAgB,iBAAiB,IAA+C;CAC9E,OAAO,KAAK,IAAI,OAAO,GAAG,IAAI,GAAG,GAAG,MAAM,QAAQ,MAAM,CAAC;AAC3D"}
|
package/dist/engine/index.cjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_engine_retry = require("./retry.cjs");
|
|
3
|
+
const require_engine_duration = require("./duration.cjs");
|
|
2
4
|
const require_engine_bucket = require("./bucket.cjs");
|
|
3
5
|
const require_engine_condition = require("./condition.cjs");
|
|
4
6
|
const require_engine_time_window = require("./time-window.cjs");
|
|
@@ -10,6 +12,9 @@ exports.PROFILE_UPDATED_EVENT = require_engine_condition.PROFILE_UPDATED_EVENT;
|
|
|
10
12
|
exports.RegistryActionInvoker = require_engine_actions.RegistryActionInvoker;
|
|
11
13
|
exports.evaluateCondition = require_engine_condition.evaluateCondition;
|
|
12
14
|
exports.hashToBucket = require_engine_bucket.hashToBucket;
|
|
15
|
+
exports.isRetryable = require_engine_retry.isRetryable;
|
|
16
|
+
exports.isRetryableStatus = require_engine_retry.isRetryableStatus;
|
|
17
|
+
exports.longestJourneyMs = require_engine_duration.longestJourneyMs;
|
|
13
18
|
exports.matchesWhere = require_engine_condition.matchesWhere;
|
|
14
19
|
exports.murmur3 = require_engine_bucket.murmur3;
|
|
15
20
|
exports.nextWindowStart = require_engine_time_window.nextWindowStart;
|
package/dist/engine/index.d.cts
CHANGED
|
@@ -5,4 +5,6 @@ import { DEVICES_UPDATED_EVENT, EvaluateOptions, PROFILE_UPDATED_EVENT, evaluate
|
|
|
5
5
|
import { runJourney } from "./interpreter.cjs";
|
|
6
6
|
import { scaleDurations, scaleSegmentWindows } from "./time-scale.cjs";
|
|
7
7
|
import { nextWindowStart, resolveTimeZone } from "./time-window.cjs";
|
|
8
|
-
|
|
8
|
+
import { longestJourneyMs } from "./duration.cjs";
|
|
9
|
+
import { isRetryable, isRetryableStatus } from "./retry.cjs";
|
|
10
|
+
export { type ActionInvocation, type ActionInvoker, type ActionVersionPolicy, DEVICES_UPDATED_EVENT, type EngineStep, type EvaluateOptions, type EventSink, type FactSource, type JourneyContext, type JourneyObserver, type JourneyOutcome, type LinkPurpose, type MessageSender, type MessageTracking, type OutboundMessage, PROFILE_UPDATED_EVENT, type PushDevice, type RegisteredAction, RegistryActionInvoker, type SendResult, evaluateCondition, hashToBucket, isRetryable, isRetryableStatus, longestJourneyMs, matchesWhere, murmur3, nextWindowStart, relevantEvents, resolveTimeZone, runJourney, scaleDurations, scaleSegmentWindows };
|
package/dist/engine/index.d.mts
CHANGED
|
@@ -5,4 +5,6 @@ import { DEVICES_UPDATED_EVENT, EvaluateOptions, PROFILE_UPDATED_EVENT, evaluate
|
|
|
5
5
|
import { runJourney } from "./interpreter.mjs";
|
|
6
6
|
import { scaleDurations, scaleSegmentWindows } from "./time-scale.mjs";
|
|
7
7
|
import { nextWindowStart, resolveTimeZone } from "./time-window.mjs";
|
|
8
|
-
|
|
8
|
+
import { longestJourneyMs } from "./duration.mjs";
|
|
9
|
+
import { isRetryable, isRetryableStatus } from "./retry.mjs";
|
|
10
|
+
export { type ActionInvocation, type ActionInvoker, type ActionVersionPolicy, DEVICES_UPDATED_EVENT, type EngineStep, type EvaluateOptions, type EventSink, type FactSource, type JourneyContext, type JourneyObserver, type JourneyOutcome, type LinkPurpose, type MessageSender, type MessageTracking, type OutboundMessage, PROFILE_UPDATED_EVENT, type PushDevice, type RegisteredAction, RegistryActionInvoker, type SendResult, evaluateCondition, hashToBucket, isRetryable, isRetryableStatus, longestJourneyMs, matchesWhere, murmur3, nextWindowStart, relevantEvents, resolveTimeZone, runJourney, scaleDurations, scaleSegmentWindows };
|
package/dist/engine/index.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { isRetryable, isRetryableStatus } from "./retry.mjs";
|
|
2
|
+
import { longestJourneyMs } from "./duration.mjs";
|
|
1
3
|
import { hashToBucket, murmur3 } from "./bucket.mjs";
|
|
2
4
|
import { DEVICES_UPDATED_EVENT, PROFILE_UPDATED_EVENT, evaluateCondition, matchesWhere, relevantEvents } from "./condition.mjs";
|
|
3
5
|
import { nextWindowStart, resolveTimeZone } from "./time-window.mjs";
|
|
4
6
|
import { runJourney } from "./interpreter.mjs";
|
|
5
7
|
import { scaleDurations, scaleSegmentWindows } from "./time-scale.mjs";
|
|
6
8
|
import { RegistryActionInvoker } from "./actions.mjs";
|
|
7
|
-
export { DEVICES_UPDATED_EVENT, PROFILE_UPDATED_EVENT, RegistryActionInvoker, evaluateCondition, hashToBucket, matchesWhere, murmur3, nextWindowStart, relevantEvents, resolveTimeZone, runJourney, scaleDurations, scaleSegmentWindows };
|
|
9
|
+
export { DEVICES_UPDATED_EVENT, PROFILE_UPDATED_EVENT, RegistryActionInvoker, evaluateCondition, hashToBucket, isRetryable, isRetryableStatus, longestJourneyMs, matchesWhere, murmur3, nextWindowStart, relevantEvents, resolveTimeZone, runJourney, scaleDurations, scaleSegmentWindows };
|