@getvitops/astro 8.1.0 → 8.2.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/CHANGELOG.md +824 -33
- package/dist/config-adapters-CbTywgEY.mjs +71 -0
- package/dist/config-adapters-CbTywgEY.mjs.map +1 -0
- package/dist/exports/tracking.d.mts +1 -1
- package/dist/exports/tracking.mjs +1 -1
- package/dist/index.d.mts +65 -48
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +240 -32
- package/dist/index.mjs.map +1 -1
- package/dist/middleware.d.mts +23 -0
- package/dist/middleware.d.mts.map +1 -0
- package/dist/middleware.mjs +34 -0
- package/dist/middleware.mjs.map +1 -0
- package/dist/routes/conversion.d.mts +131 -16
- package/dist/routes/conversion.d.mts.map +1 -1
- package/dist/routes/conversion.mjs +133 -28
- package/dist/routes/conversion.mjs.map +1 -1
- package/dist/{tracking-CmSfxWC3.mjs → tracking-Cb3A0BrC.mjs} +3 -4
- package/dist/tracking-Cb3A0BrC.mjs.map +1 -0
- package/dist/{tracking-DdEKExlE.d.mts → tracking-D2xOzjFD.d.mts} +6 -26
- package/dist/tracking-D2xOzjFD.d.mts.map +1 -0
- package/package.json +12 -8
- package/src/ads.ts +52 -16
- package/src/analytics.ts +98 -11
- package/src/components/Analytics.astro +5 -1
- package/src/components/CookieConsent.astro +2 -1
- package/src/components/Icon.astro +8 -1
- package/src/components/ReviewCardBody.astro +5 -2
- package/src/components/Tracking.astro +2 -1
- package/src/schemas/Article.astro +2 -1
- package/src/schemas/BookActions.astro +2 -1
- package/src/schemas/Breadcrumb.astro +2 -1
- package/src/schemas/Carousel.astro +2 -1
- package/src/schemas/CourseList.astro +2 -1
- package/src/schemas/Dataset.astro +2 -1
- package/src/schemas/DiscussionForum.astro +2 -1
- package/src/schemas/EducationQA.astro +2 -1
- package/src/schemas/EmployerAggregateRating.astro +2 -1
- package/src/schemas/Event.astro +2 -1
- package/src/schemas/FAQ.astro +2 -1
- package/src/schemas/FactCheck.astro +2 -1
- package/src/schemas/ImageMetadata.astro +2 -1
- package/src/schemas/JobPosting.astro +2 -1
- package/src/schemas/LocalBusiness.astro +2 -1
- package/src/schemas/MathSolver.astro +2 -1
- package/src/schemas/MovieCarousel.astro +2 -1
- package/src/schemas/Organization.astro +2 -1
- package/src/schemas/Product.astro +2 -1
- package/src/schemas/ProfilePage.astro +2 -1
- package/src/schemas/QAPage.astro +2 -1
- package/src/schemas/Recipe.astro +2 -1
- package/src/schemas/Review.astro +2 -1
- package/src/schemas/SoftwareApp.astro +2 -1
- package/src/scripts/tracking.ts +114 -21
- package/src/seo.ts +6 -5
- package/src/tracking.ts +6 -10
- package/virtual.d.ts +17 -1
- package/dist/tracking-CmSfxWC3.mjs.map +0 -1
- package/dist/tracking-DdEKExlE.d.mts.map +0 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { MiddlewareHandler } from "astro";
|
|
2
|
+
export interface AbTestingExperiment {
|
|
3
|
+
/** The cookie this experiment is assigned/pinned through. */
|
|
4
|
+
cookieName: string;
|
|
5
|
+
/** Seconds. Omitted means a session cookie. */
|
|
6
|
+
cookieMaxAge?: number;
|
|
7
|
+
/** Fraction of new visitors auto-assigned to `variants[1]` (default 0.5). */
|
|
8
|
+
splitRatio?: number;
|
|
9
|
+
/** Exactly two variant names — `variants[0]` is the default/control. */
|
|
10
|
+
variants: readonly [string, string];
|
|
11
|
+
}
|
|
12
|
+
export interface VitopsAbTestingOptions {
|
|
13
|
+
/** Keyed the same way as `site.experiments` — pass only the enabled entries. */
|
|
14
|
+
experiments: Record<string, AbTestingExperiment>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Picks `variants[1]` with probability `splitRatio`, else `variants[0]`.
|
|
18
|
+
* Exported so the assignment policy itself — not just its wiring — is
|
|
19
|
+
* directly testable without going through a request.
|
|
20
|
+
*/
|
|
21
|
+
export declare function assignVariant(experiment: Pick<AbTestingExperiment, 'splitRatio' | 'variants'>, random?: () => number): string;
|
|
22
|
+
export declare function vitopsAbTesting(opts: VitopsAbTestingOptions): MiddlewareHandler;
|
|
23
|
+
//# sourceMappingURL=middleware.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.d.mts","names":[],"sources":["../src/middleware.ts"],"mappings":";iBAqCiB;;EAEf;;EAEA;;EAEA;;EAEA;;iBAGe;;EAEf,aAAa,eAAe;;;;;;;wBAQd,cACd,YAAY,KAAK,iDACjB;wBAOc,gBAAgB,MAAM,yBAAyB"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// src/middleware.ts
|
|
2
|
+
/**
|
|
3
|
+
* Picks `variants[1]` with probability `splitRatio`, else `variants[0]`.
|
|
4
|
+
* Exported so the assignment policy itself — not just its wiring — is
|
|
5
|
+
* directly testable without going through a request.
|
|
6
|
+
*/
|
|
7
|
+
function assignVariant(experiment, random = Math.random) {
|
|
8
|
+
return random() < (experiment.splitRatio ?? .5) ? experiment.variants[1] : experiment.variants[0];
|
|
9
|
+
}
|
|
10
|
+
function vitopsAbTesting(opts) {
|
|
11
|
+
const handler = (context, next) => {
|
|
12
|
+
const secure = context.url.protocol === "https:";
|
|
13
|
+
for (const experiment of Object.values(opts.experiments)) {
|
|
14
|
+
const cookieOpts = {
|
|
15
|
+
path: "/",
|
|
16
|
+
sameSite: "lax",
|
|
17
|
+
secure,
|
|
18
|
+
...experiment.cookieMaxAge != null ? { maxAge: experiment.cookieMaxAge } : {}
|
|
19
|
+
};
|
|
20
|
+
const queryValue = context.url.searchParams.get(experiment.cookieName);
|
|
21
|
+
if (queryValue && experiment.variants.includes(queryValue)) {
|
|
22
|
+
context.cookies.set(experiment.cookieName, queryValue, cookieOpts);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (context.cookies.get(experiment.cookieName)?.value) continue;
|
|
26
|
+
context.cookies.set(experiment.cookieName, assignVariant(experiment), cookieOpts);
|
|
27
|
+
}
|
|
28
|
+
return next();
|
|
29
|
+
};
|
|
30
|
+
return handler;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { assignVariant, vitopsAbTesting };
|
|
34
|
+
//# sourceMappingURL=middleware.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.mjs","names":[],"sources":["../src/middleware.ts"],"sourcesContent":["/**\n * `vitopsAbTesting()` — the assignment half of runtime A/B experiments (see\n * `site.experiments` in `@getvitops/generator`). A consumer wires this into\n * their own `src/middleware.ts`:\n *\n * ```ts\n * import { defineConfig } from 'astro:config';\n * import { vitopsAbTesting } from '@getvitops/astro/middleware';\n * import config from '../site.json' with { type: 'json' };\n *\n * export const onRequest = vitopsAbTesting({ experiments: config.site.experiments ?? {} });\n * ```\n *\n * The first Astro middleware this toolchain ships, and the first place it\n * calls `Astro.cookies.set()` — every existing cookie write elsewhere is\n * client-side `document.cookie`. Both are deliberate: assigning a visitor a\n * variant, once, before the page renders, is exactly what a middleware is for.\n *\n * **What this does NOT do**: check consent before setting the assignment\n * cookie. Every other demand in this toolchain (`_ac`, the ad-click\n * attribution cookie) is raised by a client-side `require()` call — a\n * concept that doesn't have a server-side equivalent here yet. The\n * *reporting* half (`@getvitops/astro/analytics`'s per-provider bootstrap\n * extensions) already piggybacks on each provider's own existing consent\n * gate, so nothing about this ships ungated data to a third party — but the\n * cookie itself is set unconditionally on first visit. `experiment.category`\n * is validated and carried through the config for when this gets a real\n * answer; treat its absence here as a known, stated gap, not an oversight.\n */\nimport type { MiddlewareHandler } from 'astro';\n\n// `astro:middleware`'s `defineMiddleware()` is an identity helper meant for\n// inference inside a real Astro project's own `src/middleware.ts` — that\n// virtual module isn't resolvable from a library package's own build. Typing\n// the returned function directly against the real `MiddlewareHandler` type\n// (imported from `astro` itself, not the virtual module) does the same job.\n\nexport interface AbTestingExperiment {\n /** The cookie this experiment is assigned/pinned through. */\n cookieName: string;\n /** Seconds. Omitted means a session cookie. */\n cookieMaxAge?: number;\n /** Fraction of new visitors auto-assigned to `variants[1]` (default 0.5). */\n splitRatio?: number;\n /** Exactly two variant names — `variants[0]` is the default/control. */\n variants: readonly [string, string];\n}\n\nexport interface VitopsAbTestingOptions {\n /** Keyed the same way as `site.experiments` — pass only the enabled entries. */\n experiments: Record<string, AbTestingExperiment>;\n}\n\n/**\n * Picks `variants[1]` with probability `splitRatio`, else `variants[0]`.\n * Exported so the assignment policy itself — not just its wiring — is\n * directly testable without going through a request.\n */\nexport function assignVariant(\n experiment: Pick<AbTestingExperiment, 'splitRatio' | 'variants'>,\n random = Math.random,\n): string {\n return random() < (experiment.splitRatio ?? 0.5)\n ? experiment.variants[1]\n : experiment.variants[0];\n}\n\nexport function vitopsAbTesting(opts: VitopsAbTestingOptions): MiddlewareHandler {\n const handler: MiddlewareHandler = (context, next) => {\n const secure = context.url.protocol === 'https:';\n\n for (const experiment of Object.values(opts.experiments)) {\n const cookieOpts = {\n path: '/',\n sameSite: 'lax' as const,\n secure,\n ...(experiment.cookieMaxAge != null ? { maxAge: experiment.cookieMaxAge } : {}),\n };\n\n // A recognized query param (named after the cookie itself) is checked\n // FIRST, regardless of whether a cookie already exists — this is what\n // makes a shared preview link (\"?<cookieName>=<variant>\") reliably force\n // that variant, even for a visitor already pinned to the other one.\n // Combined with `splitRatio: 0` (nobody auto-assigned), this is the\n // blue/green-style \"100% on control, but a manual link reveals the\n // other variant\" mode with no special-cased logic of its own.\n const queryValue = context.url.searchParams.get(experiment.cookieName);\n if (queryValue && (experiment.variants as readonly string[]).includes(queryValue)) {\n context.cookies.set(experiment.cookieName, queryValue, cookieOpts);\n continue;\n }\n\n // Otherwise: assign only when nothing is set yet. Never inspect or\n // \"correct\" an existing value against the current splitRatio — that's\n // what keeps an existing assignment (manual or random) pinned.\n if (context.cookies.get(experiment.cookieName)?.value) continue;\n context.cookies.set(experiment.cookieName, assignVariant(experiment), cookieOpts);\n }\n\n return next();\n };\n return handler;\n}\n"],"mappings":";;;;;;AA0DA,SAAgB,cACd,YACA,SAAS,KAAK,QACN;CACR,OAAO,OAAO,KAAK,WAAW,cAAc,MACxC,WAAW,SAAS,KACpB,WAAW,SAAS;AAC1B;AAEA,SAAgB,gBAAgB,MAAiD;CAC/E,MAAM,WAA8B,SAAS,SAAS;EACpD,MAAM,SAAS,QAAQ,IAAI,aAAa;EAExC,KAAK,MAAM,cAAc,OAAO,OAAO,KAAK,WAAW,GAAG;GACxD,MAAM,aAAa;IACjB,MAAM;IACN,UAAU;IACV;IACA,GAAI,WAAW,gBAAgB,OAAO,EAAE,QAAQ,WAAW,aAAa,IAAI,CAAC;GAC/E;GASA,MAAM,aAAa,QAAQ,IAAI,aAAa,IAAI,WAAW,UAAU;GACrE,IAAI,cAAe,WAAW,SAA+B,SAAS,UAAU,GAAG;IACjF,QAAQ,QAAQ,IAAI,WAAW,YAAY,YAAY,UAAU;IACjE;GACF;GAKA,IAAI,QAAQ,QAAQ,IAAI,WAAW,UAAU,CAAC,EAAE,OAAO;GACvD,QAAQ,QAAQ,IAAI,WAAW,YAAY,cAAc,UAAU,GAAG,UAAU;EAClF;EAEA,OAAO,KAAK;CACd;CACA,OAAO;AACT"}
|
|
@@ -1,9 +1,74 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { Config } from "@getvitops/generator";
|
|
2
|
+
import { AdPropertySetup } from "@getvitops/utils/ads";
|
|
3
|
+
import { Localizable } from "@getvitops/utils/content";
|
|
4
|
+
import { CrmConfig, CrmCredentials, CrmResult } from "@getvitops/utils/crm";
|
|
5
|
+
import { EmailBinding, GoogleAdsCredentials, GoogleAdsUploadResult, NotifyContext, notify } from "@getvitops/utils/notify";
|
|
6
|
+
/**
|
|
7
|
+
* The slice of a `Config` that `resolveOrganizationName` reads. Structural on
|
|
8
|
+
* purpose: `import config from './site.json'` types its literals widened
|
|
9
|
+
* (`colors.utilities` becomes `string[]`, not the schema's union), so it is not
|
|
10
|
+
* assignable to the full `Config`, and every scaffolded site calls this with
|
|
11
|
+
* exactly such an import. A resolved `Config` still satisfies it.
|
|
12
|
+
*/
|
|
13
|
+
interface OrganizationNameSource {
|
|
14
|
+
organization?: {
|
|
15
|
+
name?: Localizable | undefined;
|
|
16
|
+
legalName?: string | undefined;
|
|
17
|
+
} | undefined;
|
|
18
|
+
site: {
|
|
19
|
+
defaultLocale: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolves the one display name for a site's organization — `organization.name`
|
|
24
|
+
* (localizable; `locale` picks which one, defaulting to `site.defaultLocale`,
|
|
25
|
+
* which is also the fallback if the pick has no entry), falling back to
|
|
26
|
+
* `legalName` when `name` isn't set at all, and to `undefined` (never `''`)
|
|
27
|
+
* when neither resolves to anything — `t()` returns `''` for a genuinely
|
|
28
|
+
* missing translation, and an empty name is worse than none.
|
|
29
|
+
*
|
|
30
|
+
* This is the one place that fallback chain is written. An EmDash site's own
|
|
31
|
+
* `getSiteSettings().title` is a separate, runtime-only fact (EmDash's
|
|
32
|
+
* settings aren't readable at Astro build/config time at all — see
|
|
33
|
+
* AGENTS.md's "EmDash plugin as the design-system editing surface") and stays
|
|
34
|
+
* authoritative for what it drives (the rendered page chrome); this helper is
|
|
35
|
+
* for the build-time-only consumers that can't reach EmDash at all — favicon
|
|
36
|
+
* generation, generated legal documents, notification emails — so a scaffold
|
|
37
|
+
* needing a name in `astro.config.mjs` has one real source instead of a third
|
|
38
|
+
* hardcoded string.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveOrganizationName(config: OrganizationNameSource, locale?: string): string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Builds the `context` option `createConversionRoute()` expects, straight from a
|
|
43
|
+
* resolved `Config` — instead of every consumer hand-writing a `NotifyContext`
|
|
44
|
+
* literal.
|
|
45
|
+
*
|
|
46
|
+
* `locationEmails` is primary-first, then every other location with an `email`,
|
|
47
|
+
* in `organization.locations`' own key order — `planNotifications` picks the
|
|
48
|
+
* first that's set, so order here is the whole precedence.
|
|
49
|
+
*/
|
|
50
|
+
export declare function toNotifyContext(config: Config, locale?: string): NotifyContext;
|
|
51
|
+
/**
|
|
52
|
+
* `site.crm` IS `CrmConfig` — both are the schema's inferred type from `@getvitops/config` — so
|
|
53
|
+
* this returns its argument's field unchanged.
|
|
54
|
+
*
|
|
55
|
+
* @deprecated Pass `config.site.crm` directly. Kept so existing callers keep compiling; it
|
|
56
|
+
* stopped being an adapter when the type stopped being a copy.
|
|
57
|
+
*/
|
|
58
|
+
export declare function toCrmConfig(config: Config): CrmConfig | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* `site.ads.google`'s three server-side-relevant fields, for
|
|
61
|
+
* `createConversionRoute`'s `googleAdsOffline.config`. Undefined when the site has no Google Ads
|
|
62
|
+
* property configured at all, which `createConversionRoute` reads the same way `crm` being
|
|
63
|
+
* omitted does: the upload never runs.
|
|
64
|
+
*
|
|
65
|
+
* @deprecated Pass `config.site.ads?.google` directly — it is already assignable. Kept so existing
|
|
66
|
+
* callers keep compiling.
|
|
67
|
+
*/
|
|
68
|
+
export declare function toGoogleAdsConfig(config: Config): Pick<AdPropertySetup, 'accountId' | 'conversionActionId' | 'reporting'> | undefined;
|
|
4
69
|
/** The subset of Astro's `APIContext` this needs — typed structurally so the
|
|
5
70
|
* factory does not pin an Astro version. */
|
|
6
|
-
interface ConversionRequestContext {
|
|
71
|
+
export interface ConversionRequestContext {
|
|
7
72
|
request: Request;
|
|
8
73
|
locals?: unknown;
|
|
9
74
|
}
|
|
@@ -12,10 +77,10 @@ interface ConversionRequestContext {
|
|
|
12
77
|
* hit for that `id`, or `null` if this is the first. Pure interface — a store
|
|
13
78
|
* decides nothing, it only remembers.
|
|
14
79
|
*/
|
|
15
|
-
interface RateLimitStore {
|
|
80
|
+
export interface RateLimitStore {
|
|
16
81
|
hit(id: string, at: number): Promise<number | null> | number | null;
|
|
17
82
|
}
|
|
18
|
-
interface ConversionRouteOptions {
|
|
83
|
+
export interface ConversionRouteOptions {
|
|
19
84
|
/** Where notifications go. Build it from your site config. */
|
|
20
85
|
context: NotifyContext;
|
|
21
86
|
/**
|
|
@@ -27,13 +92,50 @@ interface ConversionRouteOptions {
|
|
|
27
92
|
* in v6, and this package peers on `>= 7`.
|
|
28
93
|
*/
|
|
29
94
|
binding?: (locals: unknown) => EmailBinding | undefined;
|
|
95
|
+
/**
|
|
96
|
+
* Push the submission into a CRM (HubSpot, Zoho Bigin or Jobber) before notifying.
|
|
97
|
+
* Credentials arrive as a function of `locals`, exactly like `binding` above
|
|
98
|
+
* — the consumer reads them from `import { env } from 'cloudflare:workers'`.
|
|
99
|
+
* Credentials are never config; that rule is absolute in this toolchain.
|
|
100
|
+
*
|
|
101
|
+
* A failed CRM write never fails the request and never blocks the honeypot/
|
|
102
|
+
* Turnstile gates above it — those run first, so a bot never reaches the
|
|
103
|
+
* CRM. The outcome is folded into the notification email's "Delivery"
|
|
104
|
+
* section (via `describeCrmResult`) so a failed write is never silent.
|
|
105
|
+
*/
|
|
106
|
+
crm?: {
|
|
107
|
+
config: CrmConfig;
|
|
108
|
+
credentials?: (locals: unknown) => CrmCredentials | undefined;
|
|
109
|
+
fetch?: typeof fetch;
|
|
110
|
+
timeoutMs?: number;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Upload the conversion to Google Ads server-side instead of relying on the
|
|
114
|
+
* visitor's browser to fire `gtag.js` — see `site.ads.google.reporting` and
|
|
115
|
+
* `conversionActionId`. Credentials arrive as a function of `locals`, same
|
|
116
|
+
* shape as `crm.credentials` and `binding` above.
|
|
117
|
+
*
|
|
118
|
+
* Only fires for an action whose `conversionActionId` is actually set (via
|
|
119
|
+
* `toGoogleAdsConfig(config)`, or hand-built the same shape) **and** whose
|
|
120
|
+
* event carries a Google click id (`gclid`/`gbraid`/`wbraid` on `_ac`) —
|
|
121
|
+
* an organic conversion has nothing for the Ads API to attribute, so it's
|
|
122
|
+
* silently skipped rather than reported as a failure. A failed upload never
|
|
123
|
+
* fails the request, matching `crm` above; the outcome folds into the same
|
|
124
|
+
* "Delivery" section via `describeGoogleAdsOfflineResult`.
|
|
125
|
+
*/
|
|
126
|
+
googleAdsOffline?: {
|
|
127
|
+
config: Pick<AdPropertySetup, 'accountId' | 'conversionActionId' | 'reporting'>;
|
|
128
|
+
credentials?: (locals: unknown) => GoogleAdsCredentials | undefined;
|
|
129
|
+
fetch?: typeof fetch;
|
|
130
|
+
};
|
|
30
131
|
/**
|
|
31
132
|
* Reject a submission before anything is sent. Return a message to refuse.
|
|
32
133
|
* This is where a consumer puts required-field checks.
|
|
33
134
|
*/
|
|
34
135
|
validate?: (body: Record<string, unknown>) => string | null;
|
|
35
|
-
/** Called with every channel outcome, delivered or not
|
|
36
|
-
|
|
136
|
+
/** Called with every channel outcome, delivered or not, plus the CRM
|
|
137
|
+
* outcome when `crm` is configured. Defaults to console. */
|
|
138
|
+
onResult?: (result: ConversionResult) => void;
|
|
37
139
|
/** Injected in tests. */
|
|
38
140
|
now?: () => number;
|
|
39
141
|
/**
|
|
@@ -62,10 +164,19 @@ interface ConversionRouteOptions {
|
|
|
62
164
|
/**
|
|
63
165
|
* Reject a submission whose `Content-Length` exceeds this many bytes. Default
|
|
64
166
|
* 25_000 — generous for a contact form with no file upload, tiny for anything
|
|
65
|
-
* meant to relay large payloads.
|
|
66
|
-
*
|
|
167
|
+
* meant to relay large payloads. The header is only a fast path: a chunked
|
|
168
|
+
* request carries none, so the body is also read through a byte-capped
|
|
169
|
+
* reader and refused the moment it passes the cap.
|
|
67
170
|
*/
|
|
68
171
|
maxBodyBytes?: number;
|
|
172
|
+
/**
|
|
173
|
+
* Fall back to the first `X-Forwarded-For` hop as the client IP when
|
|
174
|
+
* `CF-Connecting-IP` is absent. Default `false`: off Cloudflare the header is
|
|
175
|
+
* whatever the client sent, so honouring it lets a script mint a fresh
|
|
176
|
+
* rate-limit bucket per request. Turn on only behind a proxy that overwrites
|
|
177
|
+
* it. With neither header trusted there is no IP, and rate limiting is skipped.
|
|
178
|
+
*/
|
|
179
|
+
trustForwardedFor?: boolean;
|
|
69
180
|
/**
|
|
70
181
|
* Reject a submission whose `Origin` header doesn't match this origin.
|
|
71
182
|
* Defaults to `context.canonical`'s origin. Pass `false` to disable.
|
|
@@ -109,6 +220,14 @@ interface ConversionRouteOptions {
|
|
|
109
220
|
required?: boolean;
|
|
110
221
|
};
|
|
111
222
|
}
|
|
223
|
+
/** `notify()`'s own result, plus the CRM and Google Ads offline-upload
|
|
224
|
+
* outcomes when those are configured. Additive over
|
|
225
|
+
* `Awaited<ReturnType<typeof notify>>` — a consumer's existing `onResult`
|
|
226
|
+
* reading `.results`/`.delivered` keeps compiling unchanged. */
|
|
227
|
+
export interface ConversionResult extends Awaited<ReturnType<typeof notify>> {
|
|
228
|
+
crm?: CrmResult | undefined;
|
|
229
|
+
googleAds?: GoogleAdsUploadResult | undefined;
|
|
230
|
+
}
|
|
112
231
|
/**
|
|
113
232
|
* Build a POST handler for conversions.
|
|
114
233
|
*
|
|
@@ -122,10 +241,6 @@ interface ConversionRouteOptions {
|
|
|
122
241
|
* submitted; refusing them because our mail did not go out turns a lost
|
|
123
242
|
* notification into a lost conversion. Failures are reported through `onResult`.
|
|
124
243
|
*/
|
|
125
|
-
declare function createConversionRoute(options: ConversionRouteOptions): ({
|
|
126
|
-
|
|
127
|
-
locals
|
|
128
|
-
}: ConversionRequestContext) => Promise<Response>;
|
|
129
|
-
//#endregion
|
|
130
|
-
export { ConversionRequestContext, ConversionRouteOptions, RateLimitStore, createConversionRoute };
|
|
244
|
+
export declare function createConversionRoute(options: ConversionRouteOptions): ({ request, locals }: ConversionRequestContext) => Promise<Response>;
|
|
245
|
+
export {};
|
|
131
246
|
//# sourceMappingURL=conversion.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/conversion.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/config-adapters.ts","../../src/routes/conversion.ts"],"mappings":";;;;;;;;;;;;UA6BiB;EACf;IAAiB,OAAO;IAAyB;;EACjD;IAAQ;;;;;;;;;;;;;;;;;;;;;wBAqBM,wBACd,QAAQ,wBACR;;;;;;;;;;wBAoBc,gBAAgB,QAAQ,QAAQ,kBAAkB;;;;;;;;wBAuBlD,YAAY,QAAQ,SAAS;;;;;;;;;;wBAa7B,kBACd,QAAQ,SACP,KAAK;;;iBCVS;EACf,SAAS;EACT;;;;;;;iBAQe;EACf,IAAI,YAAY,aAAa;;iBAkBd;;EAEf,SAAS;;;;;;;;;EAST,WAAW,oBAAoB;;;;;;;;;;;;EAY/B;IACE,QAAQ;IACR,eAAe,oBAAoB;IACnC,eAAe;IACf;;;;;;;;;;;;;;;;EAgBF;IACE,QAAQ,KAAK;IACb,eAAe,oBAAoB;IACnC,eAAe;;;;;;EAMjB,YAAY,MAAM;;;EAGlB,YAAY,QAAQ;;EAEpB;;;;;;;EAOA;;;;;;EAMA;;;;;;;;;;EAUA;;;;;;;;EAQA;;;;;;;;EAQA;;;;;;;;;;;EAWA;;;;;;;EAOA;IAAc;IAAmB,QAAQ;;;;;;;;EAOzC;IACE;;;IAGA;;;;;;;;;IASA;;;;;;;iBAQa,yBAAyB,QAAQ,kBAAkB;EAClE,MAAM;EACN,YAAY;;;;;;;;;;;;;;;wBA2GE,sBAAsB,SAAS,4BAyB/B,SAAA,UAAqB,6BAA2B,QAAQ"}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { i as toNotifyContext, n as toCrmConfig, r as toGoogleAdsConfig, t as resolveOrganizationName } from "../config-adapters-CbTywgEY.mjs";
|
|
2
|
+
import { AD_PLATFORMS, normalizeConversionActionIds } from "@getvitops/utils/ads";
|
|
3
|
+
import { deliverToCrm, describeCrmResult } from "@getvitops/utils/crm";
|
|
4
|
+
import { describeGoogleAdsOfflineResult, notify, uploadClickConversion } from "@getvitops/utils/notify";
|
|
2
5
|
import { verifyTurnstileToken } from "@getvitops/utils/security";
|
|
3
|
-
import { parseTrackingCookie } from "@getvitops/utils/tracking";
|
|
6
|
+
import { parseTrackingCookie, readCookie } from "@getvitops/utils/tracking";
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
// src/routes/conversion.ts
|
|
6
9
|
/**
|
|
7
10
|
* The conversion endpoint, as a factory.
|
|
8
11
|
*
|
|
9
|
-
* A site's contact form and its `tel:`
|
|
10
|
-
* got in touch — and
|
|
11
|
-
* off the request, build the event, notify whoever the
|
|
12
|
-
* same three lines in every project, so it ships
|
|
12
|
+
* A site's contact form and its `tel:`/`mailto:` beacons all mean the same
|
|
13
|
+
* thing — someone got in touch — and all want the same treatment: read the
|
|
14
|
+
* attribution cookie off the request, build the event, notify whoever the
|
|
15
|
+
* config says. That is the same three lines in every project, so it ships
|
|
16
|
+
* here.
|
|
13
17
|
*
|
|
14
18
|
* What deliberately does **not** ship here is field-level validation and business
|
|
15
19
|
* rules — a consumer's form has its own required fields and its own response
|
|
@@ -20,7 +24,7 @@ import { parseTrackingCookie } from "@getvitops/utils/tracking";
|
|
|
20
24
|
* unauthenticated endpoint that sends email, so leaving it wide open makes it a
|
|
21
25
|
* relay and a cost amplifier regardless of whose form is behind it.
|
|
22
26
|
*
|
|
23
|
-
* The path matters: the capture script beacons `tel:` conversions at
|
|
27
|
+
* The path matters: the capture script beacons `tel:`/`mailto:` conversions at
|
|
24
28
|
* `TRACKING_ENDPOINT` (`/api/track`, exported from `@getvitops/astro/tracking`),
|
|
25
29
|
* and the integration warns at build if no route answers it.
|
|
26
30
|
*
|
|
@@ -62,8 +66,22 @@ import { parseTrackingCookie } from "@getvitops/utils/tracking";
|
|
|
62
66
|
* request and reports a misleading "Body has already been used" instead of the
|
|
63
67
|
* actual error. This package peers on `astro >= 7`, so that form could not work
|
|
64
68
|
* for any supported consumer; `import { env } from 'cloudflare:workers'` is the
|
|
65
|
-
* current one. It also called a `toNotifyContext(config)` helper that
|
|
66
|
-
* exist — `context`
|
|
69
|
+
* current one. It also called a `toNotifyContext(config)` helper that used to
|
|
70
|
+
* not exist — `context` can now be built with `toNotifyContext(config)` (also
|
|
71
|
+
* exported from this module), rather than every consumer hand-writing a
|
|
72
|
+
* `NotifyContext` literal:
|
|
73
|
+
*
|
|
74
|
+
* ```ts
|
|
75
|
+
* import config from '../../site.json' with { type: 'json' };
|
|
76
|
+
* import { createConversionRoute, toNotifyContext, toCrmConfig } from '@getvitops/astro/routes';
|
|
77
|
+
*
|
|
78
|
+
* export const POST = createConversionRoute({
|
|
79
|
+
* context: toNotifyContext(config),
|
|
80
|
+
* crm: toCrmConfig(config) && { config: toCrmConfig(config)!, credentials: () => ({ ... }) },
|
|
81
|
+
* binding: () => env.EMAIL,
|
|
82
|
+
* redirectTo: '/thanks',
|
|
83
|
+
* });
|
|
84
|
+
* ```
|
|
67
85
|
*/
|
|
68
86
|
/**
|
|
69
87
|
* Per-isolate, in-memory default. Good enough for a single Worker instance
|
|
@@ -79,20 +97,68 @@ var MemoryRateLimitStore = class {
|
|
|
79
97
|
return prev === void 0 ? null : at - prev;
|
|
80
98
|
}
|
|
81
99
|
};
|
|
100
|
+
/** The click id to report to Google Ads, checked in the same order as
|
|
101
|
+
* `AD_PLATFORMS.google.clickIdParams` rather than `getPrimaryClickId()` —
|
|
102
|
+
* that helper returns whichever platform's click id happens to appear first
|
|
103
|
+
* in the *cross-platform* precedence table, which may not be Google's even
|
|
104
|
+
* when a `gclid` is present. */
|
|
105
|
+
function googleClickId(tracking) {
|
|
106
|
+
if (!tracking) return null;
|
|
107
|
+
for (const param of AD_PLATFORMS.google.clickIdParams) {
|
|
108
|
+
const value = tracking[param];
|
|
109
|
+
if (value) return {
|
|
110
|
+
param,
|
|
111
|
+
value: String(value)
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
82
116
|
/** Fields that are never part of a submission's meaning. `cf-turnstile-response`
|
|
83
117
|
* is excluded unconditionally, matching `render.ts`'s `OMIT_FIELDS` — the field
|
|
84
118
|
* carries a verification token, not something the visitor wrote. */
|
|
85
|
-
const CONTROL_FIELDS = new Set([
|
|
119
|
+
const CONTROL_FIELDS = /* @__PURE__ */ new Set([
|
|
86
120
|
"event",
|
|
87
121
|
"phone",
|
|
88
122
|
"cf-turnstile-response"
|
|
89
123
|
]);
|
|
124
|
+
/** The capture script sends `link.href` as-is: `tel:` + digits and the punctuation people type
|
|
125
|
+
* (a space arrives as `%20`). Not free text. */
|
|
126
|
+
const PHONE_RE = /^(?:tel:)?\+?[\d\s().%-]{3,40}$/i;
|
|
127
|
+
/** `mailto:` + an address, with an optional `?subject=…` query. A shape check, not deliverability. */
|
|
128
|
+
const EMAIL_RE = /^(?:mailto:)?[^\s@<>"?]{1,64}@[^\s@<>"?]{1,255}(?:\?[^\s<>"]{0,256})?$/i;
|
|
90
129
|
const DEFAULT_HONEYPOT_FIELDS = [
|
|
91
130
|
"website",
|
|
92
131
|
"_honeypot",
|
|
93
132
|
"honeypot"
|
|
94
133
|
];
|
|
95
134
|
const DEFAULT_MAX_BODY_BYTES = 25e3;
|
|
135
|
+
/**
|
|
136
|
+
* Read a request body, giving up as soon as it exceeds `max` bytes. Returns `null` when over
|
|
137
|
+
* the cap. `Content-Length` is optional on chunked requests, so it cannot be the only guard.
|
|
138
|
+
*/
|
|
139
|
+
async function readCapped(request, max) {
|
|
140
|
+
if (!request.body) return /* @__PURE__ */ new Uint8Array();
|
|
141
|
+
const reader = request.body.getReader();
|
|
142
|
+
const chunks = [];
|
|
143
|
+
let total = 0;
|
|
144
|
+
for (;;) {
|
|
145
|
+
const { done, value } = await reader.read();
|
|
146
|
+
if (done) break;
|
|
147
|
+
total += value.byteLength;
|
|
148
|
+
if (total > max) {
|
|
149
|
+
await reader.cancel();
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
chunks.push(value);
|
|
153
|
+
}
|
|
154
|
+
const bytes = new Uint8Array(total);
|
|
155
|
+
let offset = 0;
|
|
156
|
+
for (const chunk of chunks) {
|
|
157
|
+
bytes.set(chunk, offset);
|
|
158
|
+
offset += chunk.byteLength;
|
|
159
|
+
}
|
|
160
|
+
return bytes;
|
|
161
|
+
}
|
|
96
162
|
const DEFAULT_RATE_LIMIT_WINDOW_MS = 1e4;
|
|
97
163
|
function toFormData(body, honeypotFields) {
|
|
98
164
|
const out = {};
|
|
@@ -139,7 +205,7 @@ function redirectTo(location, status = 303) {
|
|
|
139
205
|
* notification into a lost conversion. Failures are reported through `onResult`.
|
|
140
206
|
*/
|
|
141
207
|
function createConversionRoute(options) {
|
|
142
|
-
const { context, binding, validate, onResult, now = Date.now, redirectTo: successRedirect, errorRedirectTo = successRedirect, honeypotFields = DEFAULT_HONEYPOT_FIELDS, maxBodyBytes = DEFAULT_MAX_BODY_BYTES } = options;
|
|
208
|
+
const { context, binding, validate, onResult, now = Date.now, redirectTo: successRedirect, errorRedirectTo = successRedirect, honeypotFields = DEFAULT_HONEYPOT_FIELDS, maxBodyBytes = DEFAULT_MAX_BODY_BYTES, trustForwardedFor = false } = options;
|
|
143
209
|
const allowedOrigin = options.allowedOrigin === false ? null : options.allowedOrigin ?? originOf(context.canonical);
|
|
144
210
|
const rateLimit = options.rateLimit === false ? null : {
|
|
145
211
|
windowMs: options.rateLimit?.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS,
|
|
@@ -157,29 +223,35 @@ function createConversionRoute(options) {
|
|
|
157
223
|
const requestOrigin = request.headers.get("origin");
|
|
158
224
|
if (requestOrigin && requestOrigin !== allowedOrigin) return respondError("Origin not allowed.", 403);
|
|
159
225
|
}
|
|
226
|
+
const bytes = await readCapped(request, maxBodyBytes);
|
|
227
|
+
if (!bytes) return respondError("Request too large.", 413);
|
|
160
228
|
let body;
|
|
161
229
|
try {
|
|
162
230
|
if (isFormPost) {
|
|
163
|
-
const formData = await
|
|
231
|
+
const formData = await new Response(bytes, { headers: { "content-type": contentType } }).formData();
|
|
164
232
|
body = {};
|
|
165
233
|
for (const [key, value] of formData.entries()) if (typeof value === "string") body[key] = value;
|
|
166
234
|
} else {
|
|
167
|
-
const parsed =
|
|
235
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
168
236
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return respondError("Malformed request.", 400);
|
|
169
237
|
body = parsed;
|
|
170
238
|
}
|
|
171
239
|
} catch {
|
|
172
240
|
return respondError("Malformed request.", 400);
|
|
173
241
|
}
|
|
174
|
-
const ip = request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? void 0;
|
|
242
|
+
const ip = request.headers.get("cf-connecting-ip") ?? (trustForwardedFor ? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() : null) ?? void 0;
|
|
243
|
+
const isCall = body["event"] === "call";
|
|
244
|
+
const isEmailClick = body["event"] === "email";
|
|
175
245
|
if (rateLimit && ip) {
|
|
176
|
-
const
|
|
246
|
+
const rlKey = `${ip}:${isCall ? "call" : isEmailClick ? "email" : "form"}`;
|
|
247
|
+
const sinceLastHit = await rateLimit.store.hit(rlKey, now());
|
|
177
248
|
if (sinceLastHit !== null && sinceLastHit < rateLimit.windowMs) return respondError("Too many requests.", 429);
|
|
178
249
|
}
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
const
|
|
182
|
-
|
|
250
|
+
if (isCall && !(typeof body["phone"] === "string" && PHONE_RE.test(body["phone"]))) return respondError("Missing phone.", 400);
|
|
251
|
+
if (isEmailClick && !(typeof body["email"] === "string" && EMAIL_RE.test(body["email"]))) return respondError("Missing email.", 400);
|
|
252
|
+
const isBeacon = isCall || isEmailClick;
|
|
253
|
+
const spam = !isBeacon && hasHoneypot(body, honeypotFields);
|
|
254
|
+
if (!isBeacon && !spam && options.turnstile) {
|
|
183
255
|
const { secretKey, fieldName = "cf-turnstile-response", required = false } = options.turnstile;
|
|
184
256
|
const token = body[fieldName];
|
|
185
257
|
if (typeof token === "string" && token) {
|
|
@@ -188,7 +260,7 @@ function createConversionRoute(options) {
|
|
|
188
260
|
}
|
|
189
261
|
const refusal = !spam && validate?.(body);
|
|
190
262
|
if (refusal) return respondError(refusal, 400);
|
|
191
|
-
const respondSuccess = () =>
|
|
263
|
+
const respondSuccess = () => isBeacon ? new Response(null, { status: 204 }) : isFormPost && successRedirect ? redirectTo(successRedirect) : Response.json({ success: true });
|
|
192
264
|
if (spam) {
|
|
193
265
|
onResult?.({
|
|
194
266
|
results: [{
|
|
@@ -202,20 +274,53 @@ function createConversionRoute(options) {
|
|
|
202
274
|
}
|
|
203
275
|
const turnstileFieldName = options.turnstile?.fieldName;
|
|
204
276
|
const excludedFields = turnstileFieldName ? [...honeypotFields, turnstileFieldName] : honeypotFields;
|
|
205
|
-
const
|
|
206
|
-
type: isCall ? "call" : "form",
|
|
207
|
-
...isCall ? { phone: String(body["phone"]).replace(/^tel:/, "") } : { formData: toFormData(body, excludedFields) },
|
|
277
|
+
const event = {
|
|
278
|
+
type: isCall ? "call" : isEmailClick ? "email" : "form",
|
|
279
|
+
...isCall ? { phone: String(body["phone"]).replace(/^tel:/, "") } : isEmailClick ? { email: String(body["email"]).replace(/^mailto:/, "") } : { formData: toFormData(body, excludedFields) },
|
|
208
280
|
tracking: parseTrackingCookie(request.headers.get("cookie")),
|
|
209
281
|
userAgent: request.headers.get("user-agent") ?? void 0,
|
|
210
282
|
ip,
|
|
211
283
|
at: now()
|
|
212
|
-
}
|
|
284
|
+
};
|
|
285
|
+
let crmResult = null;
|
|
286
|
+
if (!isBeacon && options.crm) crmResult = await deliverToCrm(event, options.crm.config, {
|
|
287
|
+
credentials: options.crm.credentials?.(locals),
|
|
288
|
+
fetch: options.crm.fetch,
|
|
289
|
+
timeoutMs: options.crm.timeoutMs,
|
|
290
|
+
pageContext: {
|
|
291
|
+
hutk: readCookie(request.headers.get("cookie"), "hubspotutk") ?? void 0,
|
|
292
|
+
pageUri: request.headers.get("referer") ?? void 0
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
const crmNote = describeCrmResult(crmResult);
|
|
296
|
+
let googleAdsResult = null;
|
|
297
|
+
const adsCfg = options.googleAdsOffline?.config;
|
|
298
|
+
if (adsCfg?.reporting === "server" && adsCfg.accountId) {
|
|
299
|
+
const actionId = normalizeConversionActionIds(adsCfg.conversionActionId)[event.type];
|
|
300
|
+
const clickId = googleClickId(event.tracking);
|
|
301
|
+
const credentials = options.googleAdsOffline?.credentials?.(locals);
|
|
302
|
+
if (actionId && clickId && credentials) googleAdsResult = await uploadClickConversion({
|
|
303
|
+
customerId: adsCfg.accountId,
|
|
304
|
+
conversionActionId: actionId,
|
|
305
|
+
clickId,
|
|
306
|
+
at: event.at
|
|
307
|
+
}, credentials, options.googleAdsOffline?.fetch);
|
|
308
|
+
}
|
|
309
|
+
const googleAdsNote = describeGoogleAdsOfflineResult(googleAdsResult);
|
|
310
|
+
const result = {
|
|
311
|
+
...await notify(event, context, { email: binding?.(locals) }, { notes: [crmNote, googleAdsNote].filter((n) => n != null) }),
|
|
312
|
+
...crmResult ? { crm: crmResult } : {},
|
|
313
|
+
...googleAdsResult ? { googleAds: googleAdsResult } : {}
|
|
314
|
+
};
|
|
213
315
|
if (onResult) onResult(result);
|
|
214
|
-
else
|
|
316
|
+
else {
|
|
317
|
+
for (const r of result.results) if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);
|
|
318
|
+
if (crmResult && !crmResult.ok) console.warn(`[vitops] CRM write not delivered (${crmResult.provider}): ${crmResult.reason}`);
|
|
319
|
+
if (googleAdsResult && !googleAdsResult.ok) console.warn(`[vitops] Google Ads offline upload not delivered: ${googleAdsResult.reason}`);
|
|
320
|
+
}
|
|
215
321
|
return respondSuccess();
|
|
216
322
|
};
|
|
217
323
|
}
|
|
218
324
|
|
|
219
|
-
|
|
220
|
-
export { createConversionRoute };
|
|
325
|
+
export { createConversionRoute, resolveOrganizationName, toCrmConfig, toGoogleAdsConfig, toNotifyContext };
|
|
221
326
|
//# sourceMappingURL=conversion.mjs.map
|