@shware/workflow 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +154 -2
- package/dist/_virtual/_rolldown/runtime.cjs +23 -0
- package/dist/apns-relay.cjs +105 -0
- package/dist/apns-relay.cjs.map +1 -0
- package/dist/apns-relay.d.cts +28 -0
- package/dist/apns-relay.d.cts.map +1 -0
- package/dist/apns-relay.d.mts +28 -0
- package/dist/apns-relay.d.mts.map +1 -0
- package/dist/apns-relay.mjs +104 -0
- package/dist/apns-relay.mjs.map +1 -0
- package/dist/apns.cjs +121 -0
- package/dist/apns.cjs.map +1 -0
- package/dist/apns.d.cts +63 -0
- package/dist/apns.d.cts.map +1 -0
- package/dist/apns.d.mts +63 -0
- package/dist/apns.d.mts.map +1 -0
- package/dist/apns.mjs +116 -0
- package/dist/apns.mjs.map +1 -0
- package/dist/client.cjs +125 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +108 -0
- package/dist/client.d.cts.map +1 -0
- package/dist/client.d.mts +108 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +122 -0
- package/dist/client.mjs.map +1 -0
- package/dist/cloudflare/bindings.cjs +28 -0
- package/dist/cloudflare/bindings.cjs.map +1 -0
- package/dist/cloudflare/bindings.d.cts +144 -0
- package/dist/cloudflare/bindings.d.cts.map +1 -0
- package/dist/cloudflare/bindings.d.mts +144 -0
- package/dist/cloudflare/bindings.d.mts.map +1 -0
- package/dist/cloudflare/bindings.mjs +25 -0
- package/dist/cloudflare/bindings.mjs.map +1 -0
- package/dist/cloudflare/channels.cjs +164 -0
- package/dist/cloudflare/channels.cjs.map +1 -0
- package/dist/cloudflare/channels.d.cts +29 -0
- package/dist/cloudflare/channels.d.cts.map +1 -0
- package/dist/cloudflare/channels.d.mts +29 -0
- package/dist/cloudflare/channels.d.mts.map +1 -0
- package/dist/cloudflare/channels.mjs +161 -0
- package/dist/cloudflare/channels.mjs.map +1 -0
- package/dist/cloudflare/d1-env.cjs +17 -0
- package/dist/cloudflare/d1-env.cjs.map +1 -0
- package/dist/cloudflare/d1-env.d.cts +12 -0
- package/dist/cloudflare/d1-env.d.cts.map +1 -0
- package/dist/cloudflare/d1-env.d.mts +12 -0
- package/dist/cloudflare/d1-env.d.mts.map +1 -0
- package/dist/cloudflare/d1-env.mjs +16 -0
- package/dist/cloudflare/d1-env.mjs.map +1 -0
- package/dist/cloudflare/index.cjs +54 -0
- package/dist/cloudflare/index.d.cts +19 -0
- package/dist/cloudflare/index.d.mts +19 -0
- package/dist/cloudflare/index.mjs +16 -0
- package/dist/cloudflare/router.cjs +516 -0
- package/dist/cloudflare/router.cjs.map +1 -0
- package/dist/cloudflare/router.d.cts +157 -0
- package/dist/cloudflare/router.d.cts.map +1 -0
- package/dist/cloudflare/router.d.mts +157 -0
- package/dist/cloudflare/router.d.mts.map +1 -0
- package/dist/cloudflare/router.mjs +503 -0
- package/dist/cloudflare/router.mjs.map +1 -0
- package/dist/cloudflare/runner.cjs +225 -0
- package/dist/cloudflare/runner.cjs.map +1 -0
- package/dist/cloudflare/runner.d.cts +73 -0
- package/dist/cloudflare/runner.d.cts.map +1 -0
- package/dist/cloudflare/runner.d.mts +73 -0
- package/dist/cloudflare/runner.d.mts.map +1 -0
- package/dist/cloudflare/runner.mjs +224 -0
- package/dist/cloudflare/runner.mjs.map +1 -0
- package/dist/cloudflare/schema.sql +127 -0
- package/dist/cloudflare/senders.cjs +114 -0
- package/dist/cloudflare/senders.cjs.map +1 -0
- package/dist/cloudflare/senders.d.cts +68 -0
- package/dist/cloudflare/senders.d.cts.map +1 -0
- package/dist/cloudflare/senders.d.mts +68 -0
- package/dist/cloudflare/senders.d.mts.map +1 -0
- package/dist/cloudflare/senders.mjs +108 -0
- package/dist/cloudflare/senders.mjs.map +1 -0
- package/dist/cloudflare/worker.cjs +228 -0
- package/dist/cloudflare/worker.cjs.map +1 -0
- package/dist/cloudflare/worker.d.cts +92 -0
- package/dist/cloudflare/worker.d.cts.map +1 -0
- package/dist/cloudflare/worker.d.mts +92 -0
- package/dist/cloudflare/worker.d.mts.map +1 -0
- package/dist/cloudflare/worker.mjs +224 -0
- package/dist/cloudflare/worker.mjs.map +1 -0
- package/dist/cloudflare/workers.d.cjs +0 -0
- package/dist/cloudflare/workers.d.cts +44 -0
- package/dist/cloudflare/workers.d.cts.map +1 -0
- package/dist/cloudflare/workers.d.mts +44 -0
- package/dist/cloudflare/workers.d.mts.map +1 -0
- package/dist/config.cjs +151 -0
- package/dist/config.cjs.map +1 -0
- package/dist/config.d.cts +149 -0
- package/dist/config.d.cts.map +1 -0
- package/dist/config.d.mts +149 -0
- package/dist/config.d.mts.map +1 -0
- package/dist/config.mjs +143 -0
- package/dist/config.mjs.map +1 -0
- package/dist/contract.cjs +212 -0
- package/dist/contract.cjs.map +1 -0
- package/dist/contract.d.cts +90 -0
- package/dist/contract.d.cts.map +1 -0
- package/dist/contract.d.mts +90 -0
- package/dist/contract.d.mts.map +1 -0
- package/dist/contract.mjs +206 -0
- package/dist/contract.mjs.map +1 -0
- package/dist/dsl/action.cjs +29 -0
- package/dist/dsl/action.cjs.map +1 -0
- package/dist/dsl/action.d.cts +64 -0
- package/dist/dsl/action.d.cts.map +1 -0
- package/dist/dsl/action.d.mts +64 -0
- package/dist/dsl/action.d.mts.map +1 -0
- package/dist/dsl/action.mjs +28 -0
- package/dist/dsl/action.mjs.map +1 -0
- package/dist/dsl/base.cjs +34 -0
- package/dist/dsl/base.cjs.map +1 -0
- package/dist/dsl/base.d.cts +45 -0
- package/dist/dsl/base.d.cts.map +1 -0
- package/dist/dsl/base.d.mts +45 -0
- package/dist/dsl/base.d.mts.map +1 -0
- package/dist/dsl/base.mjs +30 -0
- package/dist/dsl/base.mjs.map +1 -0
- package/dist/dsl/bundle.cjs +207 -0
- package/dist/dsl/bundle.cjs.map +1 -0
- package/dist/dsl/bundle.d.cts +19 -0
- package/dist/dsl/bundle.d.cts.map +1 -0
- package/dist/dsl/bundle.d.mts +19 -0
- package/dist/dsl/bundle.d.mts.map +1 -0
- package/dist/dsl/bundle.mjs +206 -0
- package/dist/dsl/bundle.mjs.map +1 -0
- package/dist/dsl/condition.cjs +177 -0
- package/dist/dsl/condition.cjs.map +1 -0
- package/dist/dsl/condition.d.cts +140 -0
- package/dist/dsl/condition.d.cts.map +1 -0
- package/dist/dsl/condition.d.mts +140 -0
- package/dist/dsl/condition.d.mts.map +1 -0
- package/dist/dsl/condition.mjs +156 -0
- package/dist/dsl/condition.mjs.map +1 -0
- package/dist/dsl/flow.cjs +228 -0
- package/dist/dsl/flow.cjs.map +1 -0
- package/dist/dsl/flow.d.cts +223 -0
- package/dist/dsl/flow.d.cts.map +1 -0
- package/dist/dsl/flow.d.mts +223 -0
- package/dist/dsl/flow.d.mts.map +1 -0
- package/dist/dsl/flow.mjs +226 -0
- package/dist/dsl/flow.mjs.map +1 -0
- package/dist/dsl/index.cjs +38 -0
- package/dist/dsl/index.d.cts +10 -0
- package/dist/dsl/index.d.mts +10 -0
- package/dist/dsl/index.mjs +9 -0
- package/dist/dsl/refs.cjs +59 -0
- package/dist/dsl/refs.cjs.map +1 -0
- package/dist/dsl/refs.d.cts +88 -0
- package/dist/dsl/refs.d.cts.map +1 -0
- package/dist/dsl/refs.d.mts +88 -0
- package/dist/dsl/refs.d.mts.map +1 -0
- package/dist/dsl/refs.mjs +55 -0
- package/dist/dsl/refs.mjs.map +1 -0
- package/dist/dsl/template.cjs +65 -0
- package/dist/dsl/template.cjs.map +1 -0
- package/dist/dsl/template.d.cts +75 -0
- package/dist/dsl/template.d.cts.map +1 -0
- package/dist/dsl/template.d.mts +75 -0
- package/dist/dsl/template.d.mts.map +1 -0
- package/dist/dsl/template.mjs +63 -0
- package/dist/dsl/template.mjs.map +1 -0
- package/dist/dsl/trigger.cjs +40 -0
- package/dist/dsl/trigger.cjs.map +1 -0
- package/dist/dsl/trigger.d.cts +65 -0
- package/dist/dsl/trigger.d.cts.map +1 -0
- package/dist/dsl/trigger.d.mts +65 -0
- package/dist/dsl/trigger.d.mts.map +1 -0
- package/dist/dsl/trigger.mjs +39 -0
- package/dist/dsl/trigger.mjs.map +1 -0
- package/dist/dsl/workflow.cjs +121 -0
- package/dist/dsl/workflow.cjs.map +1 -0
- package/dist/dsl/workflow.d.cts +59 -0
- package/dist/dsl/workflow.d.cts.map +1 -0
- package/dist/dsl/workflow.d.mts +59 -0
- package/dist/dsl/workflow.d.mts.map +1 -0
- package/dist/dsl/workflow.mjs +120 -0
- package/dist/dsl/workflow.mjs.map +1 -0
- package/dist/engine/actions.cjs +27 -0
- package/dist/engine/actions.cjs.map +1 -0
- package/dist/engine/actions.d.cts +40 -0
- package/dist/engine/actions.d.cts.map +1 -0
- package/dist/engine/actions.d.mts +40 -0
- package/dist/engine/actions.d.mts.map +1 -0
- package/dist/engine/actions.mjs +26 -0
- package/dist/engine/actions.mjs.map +1 -0
- package/dist/engine/bucket.cjs +72 -0
- package/dist/engine/bucket.cjs.map +1 -0
- package/dist/engine/bucket.d.cts +32 -0
- package/dist/engine/bucket.d.cts.map +1 -0
- package/dist/engine/bucket.d.mts +32 -0
- package/dist/engine/bucket.d.mts.map +1 -0
- package/dist/engine/bucket.mjs +69 -0
- package/dist/engine/bucket.mjs.map +1 -0
- package/dist/engine/condition.cjs +166 -0
- package/dist/engine/condition.cjs.map +1 -0
- package/dist/engine/condition.d.cts +52 -0
- package/dist/engine/condition.d.cts.map +1 -0
- package/dist/engine/condition.d.mts +52 -0
- package/dist/engine/condition.d.mts.map +1 -0
- package/dist/engine/condition.mjs +161 -0
- package/dist/engine/condition.mjs.map +1 -0
- package/dist/engine/index.cjs +20 -0
- package/dist/engine/index.d.cts +8 -0
- package/dist/engine/index.d.mts +8 -0
- package/dist/engine/index.mjs +7 -0
- package/dist/engine/interpreter.cjs +428 -0
- package/dist/engine/interpreter.cjs.map +1 -0
- package/dist/engine/interpreter.d.cts +6 -0
- package/dist/engine/interpreter.d.cts.map +1 -0
- package/dist/engine/interpreter.d.mts +6 -0
- package/dist/engine/interpreter.d.mts.map +1 -0
- package/dist/engine/interpreter.mjs +427 -0
- package/dist/engine/interpreter.mjs.map +1 -0
- package/dist/engine/ports.cjs +0 -0
- package/dist/engine/ports.d.cts +252 -0
- package/dist/engine/ports.d.cts.map +1 -0
- package/dist/engine/ports.d.mts +252 -0
- package/dist/engine/ports.d.mts.map +1 -0
- package/dist/engine/ports.mjs +1 -0
- package/dist/engine/recipient.cjs +23 -0
- package/dist/engine/recipient.cjs.map +1 -0
- package/dist/engine/recipient.d.cts +13 -0
- package/dist/engine/recipient.d.cts.map +1 -0
- package/dist/engine/recipient.d.mts +13 -0
- package/dist/engine/recipient.d.mts.map +1 -0
- package/dist/engine/recipient.mjs +22 -0
- package/dist/engine/recipient.mjs.map +1 -0
- package/dist/engine/time-scale.cjs +54 -0
- package/dist/engine/time-scale.cjs.map +1 -0
- package/dist/engine/time-scale.d.cts +31 -0
- package/dist/engine/time-scale.d.cts.map +1 -0
- package/dist/engine/time-scale.d.mts +31 -0
- package/dist/engine/time-scale.d.mts.map +1 -0
- package/dist/engine/time-scale.mjs +52 -0
- package/dist/engine/time-scale.mjs.map +1 -0
- package/dist/engine/time-window.cjs +149 -0
- package/dist/engine/time-window.cjs.map +1 -0
- package/dist/engine/time-window.d.cts +22 -0
- package/dist/engine/time-window.d.cts.map +1 -0
- package/dist/engine/time-window.d.mts +22 -0
- package/dist/engine/time-window.d.mts.map +1 -0
- package/dist/engine/time-window.mjs +147 -0
- package/dist/engine/time-window.mjs.map +1 -0
- package/dist/fcm.cjs +125 -0
- package/dist/fcm.cjs.map +1 -0
- package/dist/fcm.d.cts +48 -0
- package/dist/fcm.d.cts.map +1 -0
- package/dist/fcm.d.mts +48 -0
- package/dist/fcm.d.mts.map +1 -0
- package/dist/fcm.mjs +121 -0
- package/dist/fcm.mjs.map +1 -0
- package/dist/hash.cjs +245 -0
- package/dist/hash.cjs.map +1 -0
- package/dist/hash.d.cts +30 -0
- package/dist/hash.d.cts.map +1 -0
- package/dist/hash.d.mts +30 -0
- package/dist/hash.d.mts.map +1 -0
- package/dist/hash.mjs +239 -0
- package/dist/hash.mjs.map +1 -0
- package/dist/index.cjs +100 -0
- package/dist/index.d.cts +18 -0
- package/dist/index.d.mts +18 -0
- package/dist/index.mjs +17 -0
- package/dist/ir.cjs +366 -0
- package/dist/ir.cjs.map +1 -0
- package/dist/ir.d.cts +486 -0
- package/dist/ir.d.cts.map +1 -0
- package/dist/ir.d.mts +486 -0
- package/dist/ir.d.mts.map +1 -0
- package/dist/ir.mjs +344 -0
- package/dist/ir.mjs.map +1 -0
- package/dist/jwt.cjs +22 -0
- package/dist/jwt.cjs.map +1 -0
- package/dist/jwt.d.cts +9 -0
- package/dist/jwt.d.cts.map +1 -0
- package/dist/jwt.d.mts +9 -0
- package/dist/jwt.d.mts.map +1 -0
- package/dist/jwt.mjs +20 -0
- package/dist/jwt.mjs.map +1 -0
- package/dist/plan.cjs +223 -0
- package/dist/plan.cjs.map +1 -0
- package/dist/plan.d.cts +68 -0
- package/dist/plan.d.cts.map +1 -0
- package/dist/plan.d.mts +68 -0
- package/dist/plan.d.mts.map +1 -0
- package/dist/plan.mjs +221 -0
- package/dist/plan.mjs.map +1 -0
- package/dist/postgres-js.cjs +42 -0
- package/dist/postgres-js.cjs.map +1 -0
- package/dist/postgres-js.d.cts +28 -0
- package/dist/postgres-js.d.cts.map +1 -0
- package/dist/postgres-js.d.mts +28 -0
- package/dist/postgres-js.d.mts.map +1 -0
- package/dist/postgres-js.mjs +38 -0
- package/dist/postgres-js.mjs.map +1 -0
- package/dist/provenance.cjs +67 -0
- package/dist/provenance.cjs.map +1 -0
- package/dist/provenance.d.cts +41 -0
- package/dist/provenance.d.cts.map +1 -0
- package/dist/provenance.d.mts +41 -0
- package/dist/provenance.d.mts.map +1 -0
- package/dist/provenance.mjs +65 -0
- package/dist/provenance.mjs.map +1 -0
- package/dist/push.cjs +14 -0
- package/dist/push.cjs.map +1 -0
- package/dist/push.d.cts +16 -0
- package/dist/push.d.cts.map +1 -0
- package/dist/push.d.mts +16 -0
- package/dist/push.d.mts.map +1 -0
- package/dist/push.mjs +13 -0
- package/dist/push.mjs.map +1 -0
- package/dist/react-email.cjs +48 -0
- package/dist/react-email.cjs.map +1 -0
- package/dist/react-email.d.cts +7 -0
- package/dist/react-email.d.cts.map +1 -0
- package/dist/react-email.d.mts +7 -0
- package/dist/react-email.d.mts.map +1 -0
- package/dist/react-email.mjs +47 -0
- package/dist/react-email.mjs.map +1 -0
- package/dist/reporting/classify.cjs +42 -0
- package/dist/reporting/classify.cjs.map +1 -0
- package/dist/reporting/classify.d.cts +60 -0
- package/dist/reporting/classify.d.cts.map +1 -0
- package/dist/reporting/classify.d.mts +60 -0
- package/dist/reporting/classify.d.mts.map +1 -0
- package/dist/reporting/classify.mjs +38 -0
- package/dist/reporting/classify.mjs.map +1 -0
- package/dist/reporting/observer.cjs +63 -0
- package/dist/reporting/observer.cjs.map +1 -0
- package/dist/reporting/observer.d.cts +18 -0
- package/dist/reporting/observer.d.cts.map +1 -0
- package/dist/reporting/observer.d.mts +18 -0
- package/dist/reporting/observer.d.mts.map +1 -0
- package/dist/reporting/observer.mjs +62 -0
- package/dist/reporting/observer.mjs.map +1 -0
- package/dist/reporting/tracking.cjs +153 -0
- package/dist/reporting/tracking.cjs.map +1 -0
- package/dist/reporting/tracking.d.cts +43 -0
- package/dist/reporting/tracking.d.cts.map +1 -0
- package/dist/reporting/tracking.d.mts +43 -0
- package/dist/reporting/tracking.d.mts.map +1 -0
- package/dist/reporting/tracking.mjs +151 -0
- package/dist/reporting/tracking.mjs.map +1 -0
- package/dist/runtime.cjs +19 -0
- package/dist/runtime.cjs.map +1 -0
- package/dist/runtime.d.cts +30 -0
- package/dist/runtime.d.cts.map +1 -0
- package/dist/runtime.d.mts +30 -0
- package/dist/runtime.d.mts.map +1 -0
- package/dist/runtime.mjs +18 -0
- package/dist/runtime.mjs.map +1 -0
- package/dist/store/d1.cjs +185 -0
- package/dist/store/d1.cjs.map +1 -0
- package/dist/store/d1.d.cts +55 -0
- package/dist/store/d1.d.cts.map +1 -0
- package/dist/store/d1.d.mts +55 -0
- package/dist/store/d1.d.mts.map +1 -0
- package/dist/store/d1.mjs +184 -0
- package/dist/store/d1.mjs.map +1 -0
- package/dist/store/facts.cjs +60 -0
- package/dist/store/facts.cjs.map +1 -0
- package/dist/store/facts.d.cts +38 -0
- package/dist/store/facts.d.cts.map +1 -0
- package/dist/store/facts.d.mts +38 -0
- package/dist/store/facts.d.mts.map +1 -0
- package/dist/store/facts.mjs +58 -0
- package/dist/store/facts.mjs.map +1 -0
- package/dist/store/index.cjs +31 -0
- package/dist/store/index.cjs.map +1 -0
- package/dist/store/index.d.cts +291 -0
- package/dist/store/index.d.cts.map +1 -0
- package/dist/store/index.d.mts +291 -0
- package/dist/store/index.d.mts.map +1 -0
- package/dist/store/index.mjs +22 -0
- package/dist/store/index.mjs.map +1 -0
- package/dist/store/postgres-reporting.cjs +213 -0
- package/dist/store/postgres-reporting.cjs.map +1 -0
- package/dist/store/postgres-reporting.d.cts +63 -0
- package/dist/store/postgres-reporting.d.cts.map +1 -0
- package/dist/store/postgres-reporting.d.mts +63 -0
- package/dist/store/postgres-reporting.d.mts.map +1 -0
- package/dist/store/postgres-reporting.mjs +212 -0
- package/dist/store/postgres-reporting.mjs.map +1 -0
- package/dist/store/postgres.cjs +309 -0
- package/dist/store/postgres.cjs.map +1 -0
- package/dist/store/postgres.d.cts +77 -0
- package/dist/store/postgres.d.cts.map +1 -0
- package/dist/store/postgres.d.mts +77 -0
- package/dist/store/postgres.d.mts.map +1 -0
- package/dist/store/postgres.mjs +306 -0
- package/dist/store/postgres.mjs.map +1 -0
- package/dist/store/sql.cjs +24 -0
- package/dist/store/sql.cjs.map +1 -0
- package/dist/store/sql.d.cts +25 -0
- package/dist/store/sql.d.cts.map +1 -0
- package/dist/store/sql.d.mts +25 -0
- package/dist/store/sql.d.mts.map +1 -0
- package/dist/store/sql.mjs +23 -0
- package/dist/store/sql.mjs.map +1 -0
- package/dist/store/tables.cjs +404 -0
- package/dist/store/tables.cjs.map +1 -0
- package/dist/store/tables.d.cts +81 -0
- package/dist/store/tables.d.cts.map +1 -0
- package/dist/store/tables.d.mts +81 -0
- package/dist/store/tables.d.mts.map +1 -0
- package/dist/store/tables.mjs +399 -0
- package/dist/store/tables.mjs.map +1 -0
- package/dist/templates.cjs +247 -0
- package/dist/templates.cjs.map +1 -0
- package/dist/templates.d.cts +187 -0
- package/dist/templates.d.cts.map +1 -0
- package/dist/templates.d.mts +187 -0
- package/dist/templates.d.mts.map +1 -0
- package/dist/templates.mjs +230 -0
- package/dist/templates.mjs.map +1 -0
- package/dist/webhooks.cjs +96 -0
- package/dist/webhooks.cjs.map +1 -0
- package/dist/webhooks.d.cts +25 -0
- package/dist/webhooks.d.cts.map +1 -0
- package/dist/webhooks.d.mts +25 -0
- package/dist/webhooks.d.mts.map +1 -0
- package/dist/webhooks.mjs +94 -0
- package/dist/webhooks.mjs.map +1 -0
- package/dist/webpush.cjs +180 -0
- package/dist/webpush.cjs.map +1 -0
- package/dist/webpush.d.cts +53 -0
- package/dist/webpush.d.cts.map +1 -0
- package/dist/webpush.d.mts +53 -0
- package/dist/webpush.d.mts.map +1 -0
- package/dist/webpush.mjs +175 -0
- package/dist/webpush.mjs.map +1 -0
- package/package.json +189 -3
- package/src/cloudflare/schema.sql +127 -0
- package/src/cloudflare/workers.d.ts +44 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_reporting_classify = require("./classify.cjs");
|
|
3
|
+
//#region src/reporting/tracking.ts
|
|
4
|
+
const GIF = Uint8Array.from(atob("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"), (char) => char.charCodeAt(0));
|
|
5
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
function requester(request) {
|
|
7
|
+
const cf = request.cf;
|
|
8
|
+
return {
|
|
9
|
+
method: request.method,
|
|
10
|
+
userAgent: request.headers.get("user-agent"),
|
|
11
|
+
asn: cf?.asn ?? null,
|
|
12
|
+
organization: cf?.asOrganization ?? null,
|
|
13
|
+
country: cf?.country ?? null
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** A `/t/...` request's response, or undefined when the path is not a tracking route. */
|
|
17
|
+
async function handleTracking(request, reporting, options = {}) {
|
|
18
|
+
const [, root, kind, id, position] = new URL(request.url).pathname.split("/");
|
|
19
|
+
if (root !== "t" || id === void 0 || !UUID.test(id)) return void 0;
|
|
20
|
+
const who = requester(request);
|
|
21
|
+
const read = who.method === "GET" || who.method === "HEAD";
|
|
22
|
+
if (kind === "o" && read) {
|
|
23
|
+
await recordOpen(reporting, id, who, options);
|
|
24
|
+
return new Response(who.method === "HEAD" ? null : GIF, { headers: {
|
|
25
|
+
"content-type": "image/gif",
|
|
26
|
+
"cache-control": "no-store, max-age=0"
|
|
27
|
+
} });
|
|
28
|
+
}
|
|
29
|
+
if (kind === "c" && position !== void 0 && read) {
|
|
30
|
+
const target = await recordClick(reporting, id, Number(position), who, options);
|
|
31
|
+
if (target === void 0) return page(options, "This link has expired.", 404);
|
|
32
|
+
if (options.unsubscribe !== void 0 && sameLink(target, options.unsubscribe)) return page(options, `Unsubscribe from ${escape(options.product ?? "these")} emails?`, 200, `<form method="post" action="/t/u/${id}"><button type="submit">Unsubscribe</button></form>`);
|
|
33
|
+
return Response.redirect(target, 302);
|
|
34
|
+
}
|
|
35
|
+
if (kind === "d" && who.method === "POST") {
|
|
36
|
+
const known = await reporting.recordDeviceDelivered(id, Date.now());
|
|
37
|
+
return new Response(null, { status: known ? 204 : 404 });
|
|
38
|
+
}
|
|
39
|
+
if (kind === "p" && who.method === "POST") {
|
|
40
|
+
const at = Date.now();
|
|
41
|
+
let userId;
|
|
42
|
+
const known = await reporting.recordOpen(id, (message) => {
|
|
43
|
+
userId = message.userId;
|
|
44
|
+
return {
|
|
45
|
+
at,
|
|
46
|
+
machine: false,
|
|
47
|
+
reason: null,
|
|
48
|
+
...network(who)
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
if (known && userId !== void 0 && options.pushOpened !== void 0) try {
|
|
52
|
+
await options.pushOpened({
|
|
53
|
+
messageId: id,
|
|
54
|
+
userId,
|
|
55
|
+
at
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(`[workflow] recording push open ${id} as an event failed`, error);
|
|
59
|
+
}
|
|
60
|
+
return new Response(null, { status: known ? 204 : 404 });
|
|
61
|
+
}
|
|
62
|
+
if (kind === "u" && who.method === "POST") {
|
|
63
|
+
if (!await reporting.recordUnsubscribe(id, Date.now())) return page(options, "This link has expired.", 404);
|
|
64
|
+
return page(options, options.product === void 0 ? "You won't get these emails anymore." : `You won't get these emails from ${escape(options.product)} anymore.`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on
|
|
69
|
+
* the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a
|
|
70
|
+
* notification. False when the message is unknown or not the user's.
|
|
71
|
+
*/
|
|
72
|
+
async function recordPushOpen(reporting, userId, messageId, at = Date.now()) {
|
|
73
|
+
if (!UUID.test(messageId)) return false;
|
|
74
|
+
let recorded = false;
|
|
75
|
+
await reporting.recordOpen(messageId, (message) => {
|
|
76
|
+
if (message.userId !== userId) return void 0;
|
|
77
|
+
recorded = true;
|
|
78
|
+
return {
|
|
79
|
+
at,
|
|
80
|
+
machine: false,
|
|
81
|
+
reason: null,
|
|
82
|
+
userAgent: null,
|
|
83
|
+
asn: null,
|
|
84
|
+
organization: null,
|
|
85
|
+
country: null
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
return recorded;
|
|
89
|
+
}
|
|
90
|
+
async function recordOpen(reporting, id, who, options) {
|
|
91
|
+
const at = Date.now();
|
|
92
|
+
await reporting.recordOpen(id, (message) => ({
|
|
93
|
+
at,
|
|
94
|
+
...require_reporting_classify.classifyOpen({
|
|
95
|
+
...who,
|
|
96
|
+
sinceDeliveredMs: at - message.deliveredAt
|
|
97
|
+
}, { quickMs: options.quickMs }),
|
|
98
|
+
...network(who)
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
const network = (who) => ({
|
|
102
|
+
userAgent: who.userAgent,
|
|
103
|
+
asn: who.asn,
|
|
104
|
+
organization: who.organization,
|
|
105
|
+
country: who.country
|
|
106
|
+
});
|
|
107
|
+
/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */
|
|
108
|
+
async function recordClick(reporting, id, position, who, options) {
|
|
109
|
+
if (!Number.isInteger(position) || position < 0) return void 0;
|
|
110
|
+
const at = Date.now();
|
|
111
|
+
return (await reporting.recordClick(id, position, at, require_reporting_classify.BURST_WINDOW_MS, (link) => {
|
|
112
|
+
const linksInBurst = (/* @__PURE__ */ new Set([position, ...link.recent.map((click) => click.position)])).size;
|
|
113
|
+
const verdict = require_reporting_classify.classifyClick({
|
|
114
|
+
...who,
|
|
115
|
+
sinceDeliveredMs: at - link.deliveredAt,
|
|
116
|
+
linksInBurst,
|
|
117
|
+
linkCount: link.linkCount
|
|
118
|
+
}, { quickMs: options.quickMs });
|
|
119
|
+
return {
|
|
120
|
+
engagement: {
|
|
121
|
+
at,
|
|
122
|
+
...verdict,
|
|
123
|
+
...network(who)
|
|
124
|
+
},
|
|
125
|
+
backdate: verdict.reason === "link_burst" || verdict.reason === "quick+link_pair"
|
|
126
|
+
};
|
|
127
|
+
}))?.url;
|
|
128
|
+
}
|
|
129
|
+
/** The unsubscribe link may carry its own query (a token, a list): the path names it. */
|
|
130
|
+
function sameLink(target, unsubscribe) {
|
|
131
|
+
try {
|
|
132
|
+
const a = new URL(target);
|
|
133
|
+
const b = new URL(unsubscribe);
|
|
134
|
+
return a.origin === b.origin && a.pathname === b.pathname;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function escape(text) {
|
|
140
|
+
return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
141
|
+
}
|
|
142
|
+
function page(options, message, status = 200, extra = "") {
|
|
143
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(options.product ?? "Email")}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;
|
|
144
|
+
return new Response(html, {
|
|
145
|
+
status,
|
|
146
|
+
headers: { "content-type": "text/html; charset=utf-8" }
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
exports.handleTracking = handleTracking;
|
|
151
|
+
exports.recordPushOpen = recordPushOpen;
|
|
152
|
+
|
|
153
|
+
//# sourceMappingURL=tracking.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracking.cjs","names":["classifyOpen","BURST_WINDOW_MS","classifyClick"],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/c/<message>/<position> a link: records the click, redirects to the recorded URL\n * POST /t/u/<message> the unsubscribe confirmation\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. The\n * config's unsubscribe link (`emails.unsubscribe`) is a tracked link too: following it shows a\n * confirmation page, and only the confirmation — a POST, which no link scanner sends — counts.\n */\n\nexport interface TrackingOptions {\n /** `emails.unsubscribe` from the config: the footer link that asks for confirmation instead of redirecting. */\n unsubscribe?: string | undefined;\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `emails.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const [, root, kind, id, position] = url.pathname.split('/');\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if (kind === 'c' && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n if (options.unsubscribe !== undefined && sameLink(target, options.unsubscribe)) {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n }\n return Response.redirect(target, 302);\n }\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const known = await reporting.recordUnsubscribe(id, Date.now());\n if (!known) return page(options, 'This link has expired.', 404);\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\n/** The unsubscribe link may carry its own query (a token, a list): the path names it. */\nfunction sameLink(target: string, unsubscribe: string): boolean {\n try {\n const a = new URL(target);\n const b = new URL(unsubscribe);\n return a.origin === b.origin && a.pathname === b.pathname;\n } catch {\n return false;\n }\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { JourneyReporting } from "../store/index.cjs";
|
|
2
|
+
//#region src/reporting/tracking.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker
|
|
5
|
+
* without a bearer — recipients open them:
|
|
6
|
+
*
|
|
7
|
+
* GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF
|
|
8
|
+
* GET /t/c/<message>/<position> a link: records the click, redirects to the recorded URL
|
|
9
|
+
* POST /t/u/<message> the unsubscribe confirmation
|
|
10
|
+
* POST /t/d/<message> a push the device received (iOS's notification service
|
|
11
|
+
* extension, a browser's service worker)
|
|
12
|
+
* POST /t/p/<message> a push opened, reported by a browser's service worker
|
|
13
|
+
*
|
|
14
|
+
* Every open and click is recorded with its verdict (./classify.ts), and the message keeps its
|
|
15
|
+
* first open and click of each kind. The redirect goes only to the URL recorded for that position,
|
|
16
|
+
* never to one the request carries, so the endpoint cannot bounce visitors anywhere else. The
|
|
17
|
+
* config's unsubscribe link (`emails.unsubscribe`) is a tracked link too: following it shows a
|
|
18
|
+
* confirmation page, and only the confirmation — a POST, which no link scanner sends — counts.
|
|
19
|
+
*/
|
|
20
|
+
export interface TrackingOptions {
|
|
21
|
+
/** `emails.unsubscribe` from the config: the footer link that asks for confirmation instead of redirecting. */
|
|
22
|
+
unsubscribe?: string | undefined;
|
|
23
|
+
/** The name the confirmation pages show (the config's `title`). */
|
|
24
|
+
product?: string | undefined;
|
|
25
|
+
/** `emails.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */
|
|
26
|
+
quickMs?: number | undefined;
|
|
27
|
+
/** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */
|
|
28
|
+
pushOpened?: ((open: {
|
|
29
|
+
messageId: string;
|
|
30
|
+
userId: string;
|
|
31
|
+
at: number;
|
|
32
|
+
}) => Promise<void>) | undefined;
|
|
33
|
+
}
|
|
34
|
+
/** A `/t/...` request's response, or undefined when the path is not a tracking route. */
|
|
35
|
+
export declare function handleTracking(request: Request, reporting: JourneyReporting, options?: TrackingOptions): Promise<Response | undefined>;
|
|
36
|
+
/**
|
|
37
|
+
* A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on
|
|
38
|
+
* the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a
|
|
39
|
+
* notification. False when the message is unknown or not the user's.
|
|
40
|
+
*/
|
|
41
|
+
export declare function recordPushOpen(reporting: JourneyReporting, userId: string, messageId: string, at?: number): Promise<boolean>;
|
|
42
|
+
//#endregion
|
|
43
|
+
//# sourceMappingURL=tracking.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracking.d.cts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;iBAqBiB;;EAEf;;EAEA;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;wBAgC9C,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBAiEW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { JourneyReporting } from "../store/index.mjs";
|
|
2
|
+
//#region src/reporting/tracking.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker
|
|
5
|
+
* without a bearer — recipients open them:
|
|
6
|
+
*
|
|
7
|
+
* GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF
|
|
8
|
+
* GET /t/c/<message>/<position> a link: records the click, redirects to the recorded URL
|
|
9
|
+
* POST /t/u/<message> the unsubscribe confirmation
|
|
10
|
+
* POST /t/d/<message> a push the device received (iOS's notification service
|
|
11
|
+
* extension, a browser's service worker)
|
|
12
|
+
* POST /t/p/<message> a push opened, reported by a browser's service worker
|
|
13
|
+
*
|
|
14
|
+
* Every open and click is recorded with its verdict (./classify.ts), and the message keeps its
|
|
15
|
+
* first open and click of each kind. The redirect goes only to the URL recorded for that position,
|
|
16
|
+
* never to one the request carries, so the endpoint cannot bounce visitors anywhere else. The
|
|
17
|
+
* config's unsubscribe link (`emails.unsubscribe`) is a tracked link too: following it shows a
|
|
18
|
+
* confirmation page, and only the confirmation — a POST, which no link scanner sends — counts.
|
|
19
|
+
*/
|
|
20
|
+
export interface TrackingOptions {
|
|
21
|
+
/** `emails.unsubscribe` from the config: the footer link that asks for confirmation instead of redirecting. */
|
|
22
|
+
unsubscribe?: string | undefined;
|
|
23
|
+
/** The name the confirmation pages show (the config's `title`). */
|
|
24
|
+
product?: string | undefined;
|
|
25
|
+
/** `emails.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */
|
|
26
|
+
quickMs?: number | undefined;
|
|
27
|
+
/** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */
|
|
28
|
+
pushOpened?: ((open: {
|
|
29
|
+
messageId: string;
|
|
30
|
+
userId: string;
|
|
31
|
+
at: number;
|
|
32
|
+
}) => Promise<void>) | undefined;
|
|
33
|
+
}
|
|
34
|
+
/** A `/t/...` request's response, or undefined when the path is not a tracking route. */
|
|
35
|
+
export declare function handleTracking(request: Request, reporting: JourneyReporting, options?: TrackingOptions): Promise<Response | undefined>;
|
|
36
|
+
/**
|
|
37
|
+
* A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on
|
|
38
|
+
* the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a
|
|
39
|
+
* notification. False when the message is unknown or not the user's.
|
|
40
|
+
*/
|
|
41
|
+
export declare function recordPushOpen(reporting: JourneyReporting, userId: string, messageId: string, at?: number): Promise<boolean>;
|
|
42
|
+
//#endregion
|
|
43
|
+
//# sourceMappingURL=tracking.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracking.d.mts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;iBAqBiB;;EAEf;;EAEA;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;wBAgC9C,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBAiEW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { BURST_WINDOW_MS, classifyClick, classifyOpen } from "./classify.mjs";
|
|
2
|
+
//#region src/reporting/tracking.ts
|
|
3
|
+
const GIF = Uint8Array.from(atob("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"), (char) => char.charCodeAt(0));
|
|
4
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
|
+
function requester(request) {
|
|
6
|
+
const cf = request.cf;
|
|
7
|
+
return {
|
|
8
|
+
method: request.method,
|
|
9
|
+
userAgent: request.headers.get("user-agent"),
|
|
10
|
+
asn: cf?.asn ?? null,
|
|
11
|
+
organization: cf?.asOrganization ?? null,
|
|
12
|
+
country: cf?.country ?? null
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** A `/t/...` request's response, or undefined when the path is not a tracking route. */
|
|
16
|
+
async function handleTracking(request, reporting, options = {}) {
|
|
17
|
+
const [, root, kind, id, position] = new URL(request.url).pathname.split("/");
|
|
18
|
+
if (root !== "t" || id === void 0 || !UUID.test(id)) return void 0;
|
|
19
|
+
const who = requester(request);
|
|
20
|
+
const read = who.method === "GET" || who.method === "HEAD";
|
|
21
|
+
if (kind === "o" && read) {
|
|
22
|
+
await recordOpen(reporting, id, who, options);
|
|
23
|
+
return new Response(who.method === "HEAD" ? null : GIF, { headers: {
|
|
24
|
+
"content-type": "image/gif",
|
|
25
|
+
"cache-control": "no-store, max-age=0"
|
|
26
|
+
} });
|
|
27
|
+
}
|
|
28
|
+
if (kind === "c" && position !== void 0 && read) {
|
|
29
|
+
const target = await recordClick(reporting, id, Number(position), who, options);
|
|
30
|
+
if (target === void 0) return page(options, "This link has expired.", 404);
|
|
31
|
+
if (options.unsubscribe !== void 0 && sameLink(target, options.unsubscribe)) return page(options, `Unsubscribe from ${escape(options.product ?? "these")} emails?`, 200, `<form method="post" action="/t/u/${id}"><button type="submit">Unsubscribe</button></form>`);
|
|
32
|
+
return Response.redirect(target, 302);
|
|
33
|
+
}
|
|
34
|
+
if (kind === "d" && who.method === "POST") {
|
|
35
|
+
const known = await reporting.recordDeviceDelivered(id, Date.now());
|
|
36
|
+
return new Response(null, { status: known ? 204 : 404 });
|
|
37
|
+
}
|
|
38
|
+
if (kind === "p" && who.method === "POST") {
|
|
39
|
+
const at = Date.now();
|
|
40
|
+
let userId;
|
|
41
|
+
const known = await reporting.recordOpen(id, (message) => {
|
|
42
|
+
userId = message.userId;
|
|
43
|
+
return {
|
|
44
|
+
at,
|
|
45
|
+
machine: false,
|
|
46
|
+
reason: null,
|
|
47
|
+
...network(who)
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
if (known && userId !== void 0 && options.pushOpened !== void 0) try {
|
|
51
|
+
await options.pushOpened({
|
|
52
|
+
messageId: id,
|
|
53
|
+
userId,
|
|
54
|
+
at
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error(`[workflow] recording push open ${id} as an event failed`, error);
|
|
58
|
+
}
|
|
59
|
+
return new Response(null, { status: known ? 204 : 404 });
|
|
60
|
+
}
|
|
61
|
+
if (kind === "u" && who.method === "POST") {
|
|
62
|
+
if (!await reporting.recordUnsubscribe(id, Date.now())) return page(options, "This link has expired.", 404);
|
|
63
|
+
return page(options, options.product === void 0 ? "You won't get these emails anymore." : `You won't get these emails from ${escape(options.product)} anymore.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on
|
|
68
|
+
* the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a
|
|
69
|
+
* notification. False when the message is unknown or not the user's.
|
|
70
|
+
*/
|
|
71
|
+
async function recordPushOpen(reporting, userId, messageId, at = Date.now()) {
|
|
72
|
+
if (!UUID.test(messageId)) return false;
|
|
73
|
+
let recorded = false;
|
|
74
|
+
await reporting.recordOpen(messageId, (message) => {
|
|
75
|
+
if (message.userId !== userId) return void 0;
|
|
76
|
+
recorded = true;
|
|
77
|
+
return {
|
|
78
|
+
at,
|
|
79
|
+
machine: false,
|
|
80
|
+
reason: null,
|
|
81
|
+
userAgent: null,
|
|
82
|
+
asn: null,
|
|
83
|
+
organization: null,
|
|
84
|
+
country: null
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
return recorded;
|
|
88
|
+
}
|
|
89
|
+
async function recordOpen(reporting, id, who, options) {
|
|
90
|
+
const at = Date.now();
|
|
91
|
+
await reporting.recordOpen(id, (message) => ({
|
|
92
|
+
at,
|
|
93
|
+
...classifyOpen({
|
|
94
|
+
...who,
|
|
95
|
+
sinceDeliveredMs: at - message.deliveredAt
|
|
96
|
+
}, { quickMs: options.quickMs }),
|
|
97
|
+
...network(who)
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
const network = (who) => ({
|
|
101
|
+
userAgent: who.userAgent,
|
|
102
|
+
asn: who.asn,
|
|
103
|
+
organization: who.organization,
|
|
104
|
+
country: who.country
|
|
105
|
+
});
|
|
106
|
+
/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */
|
|
107
|
+
async function recordClick(reporting, id, position, who, options) {
|
|
108
|
+
if (!Number.isInteger(position) || position < 0) return void 0;
|
|
109
|
+
const at = Date.now();
|
|
110
|
+
return (await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {
|
|
111
|
+
const linksInBurst = (/* @__PURE__ */ new Set([position, ...link.recent.map((click) => click.position)])).size;
|
|
112
|
+
const verdict = classifyClick({
|
|
113
|
+
...who,
|
|
114
|
+
sinceDeliveredMs: at - link.deliveredAt,
|
|
115
|
+
linksInBurst,
|
|
116
|
+
linkCount: link.linkCount
|
|
117
|
+
}, { quickMs: options.quickMs });
|
|
118
|
+
return {
|
|
119
|
+
engagement: {
|
|
120
|
+
at,
|
|
121
|
+
...verdict,
|
|
122
|
+
...network(who)
|
|
123
|
+
},
|
|
124
|
+
backdate: verdict.reason === "link_burst" || verdict.reason === "quick+link_pair"
|
|
125
|
+
};
|
|
126
|
+
}))?.url;
|
|
127
|
+
}
|
|
128
|
+
/** The unsubscribe link may carry its own query (a token, a list): the path names it. */
|
|
129
|
+
function sameLink(target, unsubscribe) {
|
|
130
|
+
try {
|
|
131
|
+
const a = new URL(target);
|
|
132
|
+
const b = new URL(unsubscribe);
|
|
133
|
+
return a.origin === b.origin && a.pathname === b.pathname;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function escape(text) {
|
|
139
|
+
return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
140
|
+
}
|
|
141
|
+
function page(options, message, status = 200, extra = "") {
|
|
142
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(options.product ?? "Email")}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;
|
|
143
|
+
return new Response(html, {
|
|
144
|
+
status,
|
|
145
|
+
headers: { "content-type": "text/html; charset=utf-8" }
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
export { handleTracking, recordPushOpen };
|
|
150
|
+
|
|
151
|
+
//# sourceMappingURL=tracking.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracking.mjs","names":[],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/c/<message>/<position> a link: records the click, redirects to the recorded URL\n * POST /t/u/<message> the unsubscribe confirmation\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. The\n * config's unsubscribe link (`emails.unsubscribe`) is a tracked link too: following it shows a\n * confirmation page, and only the confirmation — a POST, which no link scanner sends — counts.\n */\n\nexport interface TrackingOptions {\n /** `emails.unsubscribe` from the config: the footer link that asks for confirmation instead of redirecting. */\n unsubscribe?: string | undefined;\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `emails.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const [, root, kind, id, position] = url.pathname.split('/');\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if (kind === 'c' && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n if (options.unsubscribe !== undefined && sameLink(target, options.unsubscribe)) {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n }\n return Response.redirect(target, 302);\n }\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const known = await reporting.recordUnsubscribe(id, Date.now());\n if (!known) return page(options, 'This link has expired.', 404);\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\n/** The unsubscribe link may carry its own query (a token, a list): the path names it. */\nfunction sameLink(target: string, unsubscribe: string): boolean {\n try {\n const a = new URL(target);\n const b = new URL(unsubscribe);\n return a.origin === b.origin && a.pathname === b.pathname;\n } catch {\n return false;\n }\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
|
package/dist/runtime.cjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/runtime.ts
|
|
3
|
+
const DEFAULTS = {
|
|
4
|
+
timeScale: 1,
|
|
5
|
+
logMessages: false
|
|
6
|
+
};
|
|
7
|
+
/** The options for `mode`, defaults filled in; `config` may be absent altogether. */
|
|
8
|
+
function resolveRuntime(config, mode) {
|
|
9
|
+
const entry = config?.[mode] ?? {};
|
|
10
|
+
const timeScale = entry.timeScale ?? DEFAULTS.timeScale;
|
|
11
|
+
return {
|
|
12
|
+
timeScale: Number.isFinite(timeScale) && timeScale > 0 ? timeScale : DEFAULTS.timeScale,
|
|
13
|
+
logMessages: entry.logMessages ?? DEFAULTS.logMessages
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
exports.resolveRuntime = resolveRuntime;
|
|
18
|
+
|
|
19
|
+
//# sourceMappingURL=runtime.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.cjs","names":[],"sources":["../src/runtime.ts"],"sourcesContent":["/**\n * Runtime options — how the engine should behave in a given environment, as\n * typed configuration rather than loose environment variables. A project keeps\n * one `JourneyRuntimeConfig` (typically in its `workflow.config.ts`, next to the\n * studio settings) with one entry per mode; the host resolves the entry for\n * the environment it is running in and hands it to the runner.\n *\n * The only thing that should come from the environment is the mode itself —\n * which entry applies — never the values.\n */\n\nexport interface JourneyRuntimeOptions {\n /**\n * Fast-forward factor for every duration in a journey — delays, random\n * delays, wait timeouts, goal and `performed(within)` windows scale\n * together (see engine/time-scale.ts). 3600 turns \"2 days\" into 48 s.\n * 1 (the default) is real time; anything else belongs to development only.\n */\n timeScale?: number;\n /** Log every outbound message (channel, template, recipient, resolved props) as it is sent. */\n logMessages?: boolean;\n}\n\n/** Environments a project distinguishes; any string works, these two are the conventional ones. */\nexport type JourneyMode = 'development' | 'production' | (string & {});\n\n/** One options entry per mode. Missing modes run with the defaults (real time, quiet). */\nexport type JourneyRuntimeConfig = Partial<Record<JourneyMode, JourneyRuntimeOptions>>;\n\nconst DEFAULTS: Required<JourneyRuntimeOptions> = { timeScale: 1, logMessages: false };\n\n/** The options for `mode`, defaults filled in; `config` may be absent altogether. */\nexport function resolveRuntime(\n config: JourneyRuntimeConfig | undefined,\n mode: JourneyMode\n): Required<JourneyRuntimeOptions> {\n const entry = config?.[mode] ?? {};\n const timeScale = entry.timeScale ?? DEFAULTS.timeScale;\n return {\n timeScale: Number.isFinite(timeScale) && timeScale > 0 ? timeScale : DEFAULTS.timeScale,\n logMessages: entry.logMessages ?? DEFAULTS.logMessages,\n };\n}\n"],"mappings":";;AA6BA,MAAM,WAA4C;CAAE,WAAW;CAAG,aAAa;AAAM;;AAGrF,SAAgB,eACd,QACA,MACiC;CACjC,MAAM,QAAQ,SAAS,SAAS,CAAC;CACjC,MAAM,YAAY,MAAM,aAAa,SAAS;CAC9C,OAAO;EACL,WAAW,OAAO,SAAS,SAAS,KAAK,YAAY,IAAI,YAAY,SAAS;EAC9E,aAAa,MAAM,eAAe,SAAS;CAC7C;AACF"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/runtime.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Runtime options — how the engine should behave in a given environment, as
|
|
4
|
+
* typed configuration rather than loose environment variables. A project keeps
|
|
5
|
+
* one `JourneyRuntimeConfig` (typically in its `workflow.config.ts`, next to the
|
|
6
|
+
* studio settings) with one entry per mode; the host resolves the entry for
|
|
7
|
+
* the environment it is running in and hands it to the runner.
|
|
8
|
+
*
|
|
9
|
+
* The only thing that should come from the environment is the mode itself —
|
|
10
|
+
* which entry applies — never the values.
|
|
11
|
+
*/
|
|
12
|
+
export interface JourneyRuntimeOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Fast-forward factor for every duration in a journey — delays, random
|
|
15
|
+
* delays, wait timeouts, goal and `performed(within)` windows scale
|
|
16
|
+
* together (see engine/time-scale.ts). 3600 turns "2 days" into 48 s.
|
|
17
|
+
* 1 (the default) is real time; anything else belongs to development only.
|
|
18
|
+
*/
|
|
19
|
+
timeScale?: number;
|
|
20
|
+
/** Log every outbound message (channel, template, recipient, resolved props) as it is sent. */
|
|
21
|
+
logMessages?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Environments a project distinguishes; any string works, these two are the conventional ones. */
|
|
24
|
+
export type JourneyMode = 'development' | 'production' | (string & {});
|
|
25
|
+
/** One options entry per mode. Missing modes run with the defaults (real time, quiet). */
|
|
26
|
+
export type JourneyRuntimeConfig = Partial<Record<JourneyMode, JourneyRuntimeOptions>>;
|
|
27
|
+
/** The options for `mode`, defaults filled in; `config` may be absent altogether. */
|
|
28
|
+
export declare function resolveRuntime(config: JourneyRuntimeConfig | undefined, mode: JourneyMode): Required<JourneyRuntimeOptions>;
|
|
29
|
+
//#endregion
|
|
30
|
+
//# sourceMappingURL=runtime.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.d.cts","names":[],"sources":["../src/runtime.ts"],"mappings":";;;;;;;;;;;iBAWiB;;;;;;;EAOf;;EAEA;;;YAIU;;YAGA,uBAAuB,QAAQ,OAAO,aAAa;;wBAK/C,eACd,QAAQ,kCACR,MAAM,cACL,SAAS"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/runtime.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Runtime options — how the engine should behave in a given environment, as
|
|
4
|
+
* typed configuration rather than loose environment variables. A project keeps
|
|
5
|
+
* one `JourneyRuntimeConfig` (typically in its `workflow.config.ts`, next to the
|
|
6
|
+
* studio settings) with one entry per mode; the host resolves the entry for
|
|
7
|
+
* the environment it is running in and hands it to the runner.
|
|
8
|
+
*
|
|
9
|
+
* The only thing that should come from the environment is the mode itself —
|
|
10
|
+
* which entry applies — never the values.
|
|
11
|
+
*/
|
|
12
|
+
export interface JourneyRuntimeOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Fast-forward factor for every duration in a journey — delays, random
|
|
15
|
+
* delays, wait timeouts, goal and `performed(within)` windows scale
|
|
16
|
+
* together (see engine/time-scale.ts). 3600 turns "2 days" into 48 s.
|
|
17
|
+
* 1 (the default) is real time; anything else belongs to development only.
|
|
18
|
+
*/
|
|
19
|
+
timeScale?: number;
|
|
20
|
+
/** Log every outbound message (channel, template, recipient, resolved props) as it is sent. */
|
|
21
|
+
logMessages?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Environments a project distinguishes; any string works, these two are the conventional ones. */
|
|
24
|
+
export type JourneyMode = 'development' | 'production' | (string & {});
|
|
25
|
+
/** One options entry per mode. Missing modes run with the defaults (real time, quiet). */
|
|
26
|
+
export type JourneyRuntimeConfig = Partial<Record<JourneyMode, JourneyRuntimeOptions>>;
|
|
27
|
+
/** The options for `mode`, defaults filled in; `config` may be absent altogether. */
|
|
28
|
+
export declare function resolveRuntime(config: JourneyRuntimeConfig | undefined, mode: JourneyMode): Required<JourneyRuntimeOptions>;
|
|
29
|
+
//#endregion
|
|
30
|
+
//# sourceMappingURL=runtime.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.d.mts","names":[],"sources":["../src/runtime.ts"],"mappings":";;;;;;;;;;;iBAWiB;;;;;;;EAOf;;EAEA;;;YAIU;;YAGA,uBAAuB,QAAQ,OAAO,aAAa;;wBAK/C,eACd,QAAQ,kCACR,MAAM,cACL,SAAS"}
|
package/dist/runtime.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/runtime.ts
|
|
2
|
+
const DEFAULTS = {
|
|
3
|
+
timeScale: 1,
|
|
4
|
+
logMessages: false
|
|
5
|
+
};
|
|
6
|
+
/** The options for `mode`, defaults filled in; `config` may be absent altogether. */
|
|
7
|
+
function resolveRuntime(config, mode) {
|
|
8
|
+
const entry = config?.[mode] ?? {};
|
|
9
|
+
const timeScale = entry.timeScale ?? DEFAULTS.timeScale;
|
|
10
|
+
return {
|
|
11
|
+
timeScale: Number.isFinite(timeScale) && timeScale > 0 ? timeScale : DEFAULTS.timeScale,
|
|
12
|
+
logMessages: entry.logMessages ?? DEFAULTS.logMessages
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { resolveRuntime };
|
|
17
|
+
|
|
18
|
+
//# sourceMappingURL=runtime.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.mjs","names":[],"sources":["../src/runtime.ts"],"sourcesContent":["/**\n * Runtime options — how the engine should behave in a given environment, as\n * typed configuration rather than loose environment variables. A project keeps\n * one `JourneyRuntimeConfig` (typically in its `workflow.config.ts`, next to the\n * studio settings) with one entry per mode; the host resolves the entry for\n * the environment it is running in and hands it to the runner.\n *\n * The only thing that should come from the environment is the mode itself —\n * which entry applies — never the values.\n */\n\nexport interface JourneyRuntimeOptions {\n /**\n * Fast-forward factor for every duration in a journey — delays, random\n * delays, wait timeouts, goal and `performed(within)` windows scale\n * together (see engine/time-scale.ts). 3600 turns \"2 days\" into 48 s.\n * 1 (the default) is real time; anything else belongs to development only.\n */\n timeScale?: number;\n /** Log every outbound message (channel, template, recipient, resolved props) as it is sent. */\n logMessages?: boolean;\n}\n\n/** Environments a project distinguishes; any string works, these two are the conventional ones. */\nexport type JourneyMode = 'development' | 'production' | (string & {});\n\n/** One options entry per mode. Missing modes run with the defaults (real time, quiet). */\nexport type JourneyRuntimeConfig = Partial<Record<JourneyMode, JourneyRuntimeOptions>>;\n\nconst DEFAULTS: Required<JourneyRuntimeOptions> = { timeScale: 1, logMessages: false };\n\n/** The options for `mode`, defaults filled in; `config` may be absent altogether. */\nexport function resolveRuntime(\n config: JourneyRuntimeConfig | undefined,\n mode: JourneyMode\n): Required<JourneyRuntimeOptions> {\n const entry = config?.[mode] ?? {};\n const timeScale = entry.timeScale ?? DEFAULTS.timeScale;\n return {\n timeScale: Number.isFinite(timeScale) && timeScale > 0 ? timeScale : DEFAULTS.timeScale,\n logMessages: entry.logMessages ?? DEFAULTS.logMessages,\n };\n}\n"],"mappings":";AA6BA,MAAM,WAA4C;CAAE,WAAW;CAAG,aAAa;AAAM;;AAGrF,SAAgB,eACd,QACA,MACiC;CACjC,MAAM,QAAQ,SAAS,SAAS,CAAC;CACjC,MAAM,YAAY,MAAM,aAAa,SAAS;CAC9C,OAAO;EACL,WAAW,OAAO,SAAS,SAAS,KAAK,YAAY,IAAI,YAAY,SAAS;EAC9E,aAAa,MAAM,eAAe,SAAS;CAC7C;AACF"}
|