react-marketing-tools 1.0.0-alpha.5 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +338 -24
- package/dist/attribution/attribution.d.ts +11 -4
- package/dist/autocapture/clicks.d.ts +11 -0
- package/dist/chunks/cookies.js +49 -0
- package/dist/chunks/core.js +357 -211
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/types.d.ts +58 -3
- package/dist/core.d.ts +1 -1
- package/dist/destinations/serverRelay.d.ts +34 -0
- package/dist/fingerprintjs.d.ts +8 -0
- package/dist/fingerprintjs.js +7 -0
- package/dist/identity/visitorId.d.ts +14 -0
- package/dist/journeys/journey.d.ts +8 -0
- package/dist/server/aiCrawlers.d.ts +37 -0
- package/dist/server/conversionsApi.d.ts +38 -0
- package/dist/server/createTrackHandler.d.ts +20 -0
- package/dist/server/ga4Cookies.d.ts +11 -0
- package/dist/server/measurementProtocol.d.ts +34 -0
- package/dist/server/normalize.d.ts +19 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +252 -0
- package/dist/shared/hash.d.ts +4 -0
- package/dist/shared/uuid.d.ts +1 -0
- package/dist/webVitals.d.ts +8 -0
- package/dist/webVitals.js +16 -0
- package/package.json +41 -6
package/dist/core/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type AnalyticsErrorCode = 'invalid_event' | 'invalid_param' | 'invalid_user_id' | 'invalid_consent' | 'pii_redacted' | 'destination_failed';
|
|
1
|
+
export type AnalyticsErrorCode = 'invalid_event' | 'invalid_param' | 'invalid_user_id' | 'invalid_consent' | 'pii_redacted' | 'destination_failed' | 'visitor_id_failed' | 'journey_ended';
|
|
2
2
|
/** Every problem the library reports to `onError`, or throws when `debug` is on. */
|
|
3
3
|
export declare class AnalyticsError extends Error {
|
|
4
4
|
readonly code: AnalyticsErrorCode;
|
package/dist/core/types.d.ts
CHANGED
|
@@ -15,19 +15,30 @@ export type ConsentUpdate = Partial<ConsentState>;
|
|
|
15
15
|
export type EventParams = Record<string, unknown>;
|
|
16
16
|
/** Per-call adjustments for one destination, when the automatic mapping isn't what you want. */
|
|
17
17
|
export type TrackOptions = {
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Meta Pixel and the relay: send as this event name (standard or custom), with these params merged over the mapped
|
|
20
|
+
* ones, or `false` to keep the event away from Meta.
|
|
21
|
+
*/
|
|
22
|
+
meta?: false | {
|
|
20
23
|
event?: string;
|
|
21
24
|
params?: EventParams;
|
|
22
25
|
};
|
|
23
26
|
};
|
|
24
27
|
export type CampaignParam = 'utm_source' | 'utm_medium' | 'utm_campaign' | 'utm_term' | 'utm_content' | 'utm_id' | 'utm_source_platform' | 'utm_creative_format' | 'utm_marketing_tactic' | 'gclid' | 'gbraid' | 'wbraid' | 'dclid' | 'fbclid' | 'msclkid' | 'ttclid' | 'li_fat_id' | 'twclid';
|
|
28
|
+
/**
|
|
29
|
+
* Labels for visits that arrive from AI assistants, as `{ label: [hostname, …] }`, for example
|
|
30
|
+
* `{ chatgpt: ['chatgpt.com', 'chat.openai.com'], perplexity: ['perplexity.ai'] }`. A referrer matches a hostname
|
|
31
|
+
* exactly or as a subdomain of it. The list is yours to keep up to date: the library ships none.
|
|
32
|
+
*/
|
|
33
|
+
export type AiSources = Record<string, string[]>;
|
|
25
34
|
/** Where a visit came from: the campaign params and ad click IDs of its landing URL. */
|
|
26
35
|
export type Attribution = Partial<Record<CampaignParam, string>> & {
|
|
27
36
|
/** Origin and path of the landing page, without its query string. */
|
|
28
37
|
landing_page: string;
|
|
29
38
|
/** Origin and path of the referring page, when there was one. */
|
|
30
39
|
referrer?: string;
|
|
40
|
+
/** Your label for the AI assistant this visit came from, when the referrer matched `attribution.aiSources`. */
|
|
41
|
+
ai_source?: string;
|
|
31
42
|
/** Milliseconds since the Unix epoch when the visit was captured. */
|
|
32
43
|
captured_at: number;
|
|
33
44
|
};
|
|
@@ -122,6 +133,33 @@ export type MetaPixelConfig = {
|
|
|
122
133
|
/** Set to `false` when the page already includes the Meta Pixel base code. Defaults to `true`. */
|
|
123
134
|
loadScript?: boolean;
|
|
124
135
|
};
|
|
136
|
+
/**
|
|
137
|
+
* A multi-step flow, such as a checkout. Its first call also sends `journey_start`; every event carries `journey_id` and
|
|
138
|
+
* `journey_name`.
|
|
139
|
+
*/
|
|
140
|
+
export type Journey = {
|
|
141
|
+
/** Sends `journey_step` with `step_name` and `step_index` (1 for the first step). */
|
|
142
|
+
step(stepName: string, params?: EventParams): void;
|
|
143
|
+
/** Sends `journey_complete` with `step_count`. The journey then ends. */
|
|
144
|
+
complete(params?: EventParams): void;
|
|
145
|
+
/** Sends `journey_abandon` with `step_count`, the last `step_name` and `reason`. The journey then ends. */
|
|
146
|
+
abandon(reason?: string): void;
|
|
147
|
+
};
|
|
148
|
+
export type ServerRelayConfig = {
|
|
149
|
+
/**
|
|
150
|
+
* Where to post events for your `createTrackHandler()` endpoint, which forwards them to the Meta Conversions API with
|
|
151
|
+
* the Pixel's event ID. A path on your site (`'/api/track'`) or an https URL.
|
|
152
|
+
*/
|
|
153
|
+
endpoint: string;
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* How the visitor ID is made. `'random'`: a random ID kept in the browser's storage, with analytics consent.
|
|
157
|
+
* `{ fingerprint }`: your function's browser fingerprint, with analytics and advertising consent, never stored.
|
|
158
|
+
* `false`: no visitor ID.
|
|
159
|
+
*/
|
|
160
|
+
export type VisitorIdConfig = 'random' | false | {
|
|
161
|
+
fingerprint: () => Promise<string>;
|
|
162
|
+
};
|
|
125
163
|
export type AnalyticsConfig = {
|
|
126
164
|
/** Initial consent for every purpose. Required, so every site makes an explicit choice. */
|
|
127
165
|
consent: ConsentStatus;
|
|
@@ -132,14 +170,24 @@ export type AnalyticsConfig = {
|
|
|
132
170
|
respectGpc?: boolean;
|
|
133
171
|
/**
|
|
134
172
|
* Capture UTM params and ad click IDs from landing URLs. Defaults to `true`. First and last touch are stored in the
|
|
135
|
-
* browser only with analytics consent; `ttlDays` (default 90) is how long a first touch is kept.
|
|
173
|
+
* browser only with analytics consent; `ttlDays` (default 90) is how long a first touch is kept. `aiSources` labels
|
|
174
|
+
* visits that arrive from AI assistants.
|
|
136
175
|
*/
|
|
137
176
|
attribution?: boolean | {
|
|
138
177
|
ttlDays?: number;
|
|
178
|
+
aiSources?: AiSources;
|
|
179
|
+
};
|
|
180
|
+
/** A stable ID for this visitor, only with consent and never sent to GA4. Defaults to `'random'`. */
|
|
181
|
+
visitorId?: VisitorIdConfig;
|
|
182
|
+
/** `clicks`: track clicks on elements with a `data-analytics-event` attribute. Off by default. */
|
|
183
|
+
autocapture?: {
|
|
184
|
+
clicks?: boolean;
|
|
139
185
|
};
|
|
140
186
|
gtm?: GtmConfig;
|
|
141
187
|
ga4?: Ga4Config;
|
|
142
188
|
metaPixel?: MetaPixelConfig;
|
|
189
|
+
/** Relays events to your server for the Meta Conversions API. Only with `adUserData` consent. */
|
|
190
|
+
server?: ServerRelayConfig;
|
|
143
191
|
/** Custom destinations, in addition to the built-in ones. */
|
|
144
192
|
destinations?: Destination[];
|
|
145
193
|
/** Content-Security-Policy nonce added to every script the library injects. */
|
|
@@ -156,12 +204,19 @@ export type Analytics = {
|
|
|
156
204
|
track(name: string, params?: EventParams, options?: TrackOptions): void;
|
|
157
205
|
/** Sends a `page_view` with the current `page_location` and `page_title`, plus any params given. */
|
|
158
206
|
page(params?: EventParams): void;
|
|
207
|
+
/** Starts tracking a multi-step flow. Create it once per flow (not on every render): each journey has its own ID. */
|
|
208
|
+
journey(name: string): Journey;
|
|
159
209
|
/** Associates later events with a user. `userId` must not be personal data such as an email address. */
|
|
160
210
|
identify(userId: string, traits?: IdentityTraits): void;
|
|
161
211
|
/** Forgets the identified user, e.g. on logout. */
|
|
162
212
|
reset(): void;
|
|
163
213
|
/** Campaign attribution for this visitor. Empty outside the browser. */
|
|
164
214
|
getAttribution(): AttributionSnapshot;
|
|
215
|
+
/**
|
|
216
|
+
* This visitor's ID, once `start()` has run. `undefined` without the consent it needs, with `visitorId: false`, or
|
|
217
|
+
* outside the browser.
|
|
218
|
+
*/
|
|
219
|
+
getVisitorId(): Promise<string | undefined>;
|
|
165
220
|
consent: {
|
|
166
221
|
/** Records the visitor's choice, e.g. from your consent banner. Omitted purposes keep their current state. */
|
|
167
222
|
update(update: ConsentUpdate): void;
|
package/dist/core.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { createAnalytics } from './core/createAnalytics.js';
|
|
2
2
|
export { AnalyticsError } from './core/errors.js';
|
|
3
3
|
export type { AnalyticsErrorCode } from './core/errors.js';
|
|
4
|
-
export type { Analytics, AnalyticsConfig, AnalyticsEvent, Attribution, AttributionSnapshot, CampaignParam, ConsentState, ConsentStatus, ConsentUpdate, Destination, EventParams, Ga4Config, GtmConfig, Identity, IdentityTraits, MetaPixelConfig, TrackOptions, } from './core/types.js';
|
|
4
|
+
export type { AiSources, Analytics, AnalyticsConfig, AnalyticsEvent, Attribution, AttributionSnapshot, CampaignParam, ConsentState, ConsentStatus, ConsentUpdate, Destination, EventParams, Ga4Config, GtmConfig, Identity, IdentityTraits, Journey, MetaPixelConfig, ServerRelayConfig, TrackOptions, VisitorIdConfig, } from './core/types.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { AnalyticsError } from '../core/errors.js';
|
|
2
|
+
import type { ConsentStatus, Destination, EventParams, ServerRelayConfig } from '../core/types.js';
|
|
3
|
+
export type RelayUserData = {
|
|
4
|
+
externalId?: string;
|
|
5
|
+
email?: string;
|
|
6
|
+
phone?: string;
|
|
7
|
+
firstName?: string;
|
|
8
|
+
lastName?: string;
|
|
9
|
+
fbc?: string;
|
|
10
|
+
fbp?: string;
|
|
11
|
+
};
|
|
12
|
+
/** The body posted to your endpoint and read by `createTrackHandler()`. Versioned, so pages cached before a change keep working. */
|
|
13
|
+
export type RelayPayload = {
|
|
14
|
+
v: 1;
|
|
15
|
+
/** The name the Pixel received: Meta only deduplicates events whose name and ID both match. */
|
|
16
|
+
eventName: string;
|
|
17
|
+
/** The Pixel's `eventID`. */
|
|
18
|
+
eventId: string;
|
|
19
|
+
customData: EventParams;
|
|
20
|
+
eventSourceUrl: string;
|
|
21
|
+
consent: {
|
|
22
|
+
adUserData: ConsentStatus;
|
|
23
|
+
};
|
|
24
|
+
userData: RelayUserData;
|
|
25
|
+
};
|
|
26
|
+
type ServerRelayOptions = ServerRelayConfig & {
|
|
27
|
+
/** Off when the Pixel sends its own page views: they carry no event ID, so relayed ones would be counted twice. */
|
|
28
|
+
relayPageViews: boolean;
|
|
29
|
+
/** The visitor ID, if it's allowed and available: Meta's `external_id` for a visitor who isn't identified. */
|
|
30
|
+
visitorId: () => string | undefined;
|
|
31
|
+
onError: (error: AnalyticsError) => void;
|
|
32
|
+
};
|
|
33
|
+
export declare const createServerRelayDestination: ({ endpoint, relayPageViews, visitorId, onError, }: ServerRelayOptions) => Destination;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A browser fingerprint from FingerprintJS, for `visitorId: { fingerprint: fingerprintjs() }`. Install the MIT-licensed
|
|
3
|
+
* v5 first: `npm install @fingerprintjs/fingerprintjs@^5` (4.x is under a non-open-source licence).
|
|
4
|
+
*
|
|
5
|
+
* FingerprintJS is loaded the first time the library asks for a visitor ID, which is only with the visitor's analytics
|
|
6
|
+
* and advertising consent, so apps that never get consent never download it.
|
|
7
|
+
*/
|
|
8
|
+
export declare const fingerprintjs: () => (() => Promise<string>);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { AnalyticsError } from '../core/errors.js';
|
|
2
|
+
import type { ConsentState, VisitorIdConfig } from '../core/types.js';
|
|
3
|
+
/** The caller decides when this may run: after `start()`, and it passes the current consent on every call. */
|
|
4
|
+
export type VisitorIdSource = {
|
|
5
|
+
/** The ID if it's available right now: the random ID (created on first use), or a fingerprint already computed. */
|
|
6
|
+
peek(consent: ConsentState): string | undefined;
|
|
7
|
+
get(consent: ConsentState): Promise<string | undefined>;
|
|
8
|
+
/** Forgets the ID, when analytics consent is withdrawn. */
|
|
9
|
+
erase(): void;
|
|
10
|
+
};
|
|
11
|
+
export declare const createVisitorIdSource: ({ config, onError, }: {
|
|
12
|
+
config: VisitorIdConfig;
|
|
13
|
+
onError: (error: AnalyticsError) => void;
|
|
14
|
+
}) => VisitorIdSource | undefined;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { AnalyticsError } from '../core/errors.js';
|
|
2
|
+
import type { EventParams, Journey } from '../core/types.js';
|
|
3
|
+
/** A journey built only on `track`, so every destination, validation rule and the relay apply to its events. */
|
|
4
|
+
export declare const createJourney: ({ name, track, fail, }: {
|
|
5
|
+
name: string;
|
|
6
|
+
track: (eventName: string, params: EventParams) => void;
|
|
7
|
+
fail: (error: AnalyticsError) => void;
|
|
8
|
+
}) => Journey;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MeasurementProtocolOptions, MeasurementProtocolResult } from './measurementProtocol.js';
|
|
2
|
+
/**
|
|
3
|
+
* The AI crawlers you want to recognise, as `{ label: [user-agent fragment, …] }`, for example
|
|
4
|
+
* `{ gptbot: ['GPTBot'], claudebot: ['ClaudeBot'] }`. Matching ignores case. The list is yours to keep up to date:
|
|
5
|
+
* crawlers come and go, so the library ships none.
|
|
6
|
+
*/
|
|
7
|
+
export type AiAgents = Record<string, string[]>;
|
|
8
|
+
/**
|
|
9
|
+
* How this traffic is kept out of your visitor reports. Crawler hits are not people, and mixing them in inflates every
|
|
10
|
+
* number you report.
|
|
11
|
+
*
|
|
12
|
+
* - `'separate-property'`: `measurementId` is a GA4 property used only for crawlers.
|
|
13
|
+
* - `{ trafficType }`: the events carry that `traffic_type` value, and you exclude it with a data filter in
|
|
14
|
+
* **Admin → Data settings → Data filters** of the property you send to.
|
|
15
|
+
*/
|
|
16
|
+
export type AiCrawlerReporting = 'separate-property' | {
|
|
17
|
+
trafficType: string;
|
|
18
|
+
};
|
|
19
|
+
export type AiCrawlerOptions = Omit<MeasurementProtocolOptions, 'consent'> & {
|
|
20
|
+
/** Your label for the crawler, from `matchAiAgent()`. */
|
|
21
|
+
agent: string;
|
|
22
|
+
keepSeparate: AiCrawlerReporting;
|
|
23
|
+
};
|
|
24
|
+
/** Your label for the crawler behind a user agent, or undefined when it isn't one you listed. */
|
|
25
|
+
export declare const matchAiAgent: ({ userAgent, agents, }: {
|
|
26
|
+
userAgent: string;
|
|
27
|
+
agents: AiAgents;
|
|
28
|
+
}) => string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Sends a crawler's visit to GA4, tagged so it can never be mistaken for a person: every event carries your `ai_agent`
|
|
31
|
+
* label and a `traffic_type`. You must say how the traffic is kept separate, because crawler hits in a visitor property
|
|
32
|
+
* quietly ruin sessions, engagement and conversion rates.
|
|
33
|
+
*
|
|
34
|
+
* Crawlers have no GA4 cookie, so pick the `clientId` yourself: one per crawler (`crawler.gptbot`) counts each crawler
|
|
35
|
+
* as one visitor, a random one per request counts every fetch separately.
|
|
36
|
+
*/
|
|
37
|
+
export declare const sendAiCrawlerEvent: (options: AiCrawlerOptions) => Promise<MeasurementProtocolResult>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { EventParams } from '../core/types.js';
|
|
2
|
+
import { type ConversionsApiUserData } from './normalize.js';
|
|
3
|
+
/** Graph API version used unless a call passes `graphApiVersion`. */
|
|
4
|
+
export declare const META_GRAPH_API_VERSION = "v26.0";
|
|
5
|
+
export type ConversionsApiEvent = {
|
|
6
|
+
/** A Meta standard event (`Purchase`, `Lead`, …) or a custom name. */
|
|
7
|
+
eventName: string;
|
|
8
|
+
/** The Pixel's `eventID` for the same action, so Meta counts it once. */
|
|
9
|
+
eventId?: string;
|
|
10
|
+
/** Defaults to now. */
|
|
11
|
+
eventTime?: Date;
|
|
12
|
+
/** Defaults to `'website'`. */
|
|
13
|
+
actionSource?: 'website' | 'app' | 'email' | 'phone_call' | 'chat' | 'physical_store' | 'system_generated' | 'business_messaging' | 'other';
|
|
14
|
+
/** Required for website events: the page URL. */
|
|
15
|
+
eventSourceUrl?: string;
|
|
16
|
+
userData: ConversionsApiUserData;
|
|
17
|
+
/** `value`, `currency`, `content_ids`, `contents`… */
|
|
18
|
+
customData?: EventParams;
|
|
19
|
+
};
|
|
20
|
+
export type ConversionsApiOptions = {
|
|
21
|
+
pixelId: string;
|
|
22
|
+
/** Keep it on your server, e.g. `process.env.META_CAPI_TOKEN`. */
|
|
23
|
+
accessToken: string;
|
|
24
|
+
events: ConversionsApiEvent[];
|
|
25
|
+
/** From Events Manager → Test events, to see the events there. Remove it in production. */
|
|
26
|
+
testEventCode?: string;
|
|
27
|
+
graphApiVersion?: string;
|
|
28
|
+
};
|
|
29
|
+
export type ConversionsApiResult = {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
status: number;
|
|
32
|
+
/** Meta's response, e.g. `{ events_received: 1, fbtrace_id: '…' }` or `{ error: … }`. */
|
|
33
|
+
body: unknown;
|
|
34
|
+
/** Personal data removed from `customData`. */
|
|
35
|
+
warnings: string[];
|
|
36
|
+
};
|
|
37
|
+
/** Sends events to Meta from your server. Customer information is normalised and hashed as Meta requires. */
|
|
38
|
+
export declare const sendConversionsApiEvent: (options: ConversionsApiOptions) => Promise<ConversionsApiResult>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type TrackHandlerOptions = {
|
|
2
|
+
/** Sites allowed to send events, e.g. `['https://shop.example.com']`. Other origins get 403. */
|
|
3
|
+
allowedOrigins: string[];
|
|
4
|
+
meta: {
|
|
5
|
+
pixelId: string;
|
|
6
|
+
/** Keep it on your server, e.g. `process.env.META_CAPI_TOKEN`. */
|
|
7
|
+
accessToken: string;
|
|
8
|
+
/** From Events Manager → Test events, to see the events there. Remove it in production. */
|
|
9
|
+
testEventCode?: string;
|
|
10
|
+
graphApiVersion?: string;
|
|
11
|
+
};
|
|
12
|
+
/** Receives Meta's rejections and network failures. Defaults to `console.error`. */
|
|
13
|
+
onError?: (error: Error) => void;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A Web-standard `(Request) => Response` endpoint for the `server` destination: it forwards each relayed Pixel event to
|
|
17
|
+
* the Meta Conversions API with the same event ID, so Meta counts it once. Events are never forwarded to GA4, which
|
|
18
|
+
* can't deduplicate them.
|
|
19
|
+
*/
|
|
20
|
+
export declare const createTrackHandler: (options: TrackHandlerOptions) => ((request: Request) => Promise<Response>);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GA4 client and session IDs from the browser's `_ga` and `_ga_<stream>` cookies, as sent to your server in the
|
|
3
|
+
* `Cookie` header. Needed for Measurement Protocol events to join the visitor's existing GA4 session.
|
|
4
|
+
*/
|
|
5
|
+
export declare const readGa4Cookies: ({ cookieHeader, measurementId, }: {
|
|
6
|
+
cookieHeader: string;
|
|
7
|
+
measurementId: string;
|
|
8
|
+
}) => {
|
|
9
|
+
clientId?: string;
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ConsentStatus, EventParams } from '../core/types.js';
|
|
2
|
+
export type MeasurementProtocolEvent = {
|
|
3
|
+
name: string;
|
|
4
|
+
params?: EventParams;
|
|
5
|
+
};
|
|
6
|
+
export type MeasurementProtocolOptions = {
|
|
7
|
+
measurementId: string;
|
|
8
|
+
/** Keep it on your server: Google says the API secret must not be exposed in client-side code. */
|
|
9
|
+
apiSecret: string;
|
|
10
|
+
/** The visitor's GA4 client ID, from the `_ga` cookie (see `readGa4Cookies`). */
|
|
11
|
+
clientId: string;
|
|
12
|
+
/** The visitor's GA4 session ID, from the `_ga_<stream>` cookie, so events join their session. */
|
|
13
|
+
sessionId?: string;
|
|
14
|
+
userId?: string;
|
|
15
|
+
/** Up to 25 events. Each gets `session_id` and `engagement_time_msec` (1 ms unless the params set it). */
|
|
16
|
+
events: MeasurementProtocolEvent[];
|
|
17
|
+
consent?: {
|
|
18
|
+
adUserData?: ConsentStatus;
|
|
19
|
+
adPersonalization?: ConsentStatus;
|
|
20
|
+
};
|
|
21
|
+
/** Send to the validation endpoint instead: nothing is recorded, and GA4's findings come back as `validationMessages`. */
|
|
22
|
+
validate?: boolean;
|
|
23
|
+
region?: 'global' | 'eu';
|
|
24
|
+
};
|
|
25
|
+
export type MeasurementProtocolResult = {
|
|
26
|
+
/** The request was accepted (GA4 accepts malformed payloads too; use `validate` to check them). */
|
|
27
|
+
ok: boolean;
|
|
28
|
+
status: number;
|
|
29
|
+
/** GA4 limit problems and personal data removed from params. */
|
|
30
|
+
warnings: string[];
|
|
31
|
+
validationMessages?: unknown[];
|
|
32
|
+
};
|
|
33
|
+
/** Sends events to GA4 from your server, e.g. a purchase confirmed by a payment webhook. */
|
|
34
|
+
export declare const sendMeasurementProtocolEvent: (options: MeasurementProtocolOptions) => Promise<MeasurementProtocolResult>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ConversionsApiUserData = {
|
|
2
|
+
email?: string;
|
|
3
|
+
/** Include the country code, e.g. `+44 7700 900123`. */
|
|
4
|
+
phone?: string;
|
|
5
|
+
firstName?: string;
|
|
6
|
+
lastName?: string;
|
|
7
|
+
/** Your own user ID. */
|
|
8
|
+
externalId?: string;
|
|
9
|
+
/** The visitor's IP address, e.g. from your request. */
|
|
10
|
+
clientIpAddress?: string;
|
|
11
|
+
/** The visitor's User-Agent header. */
|
|
12
|
+
clientUserAgent?: string;
|
|
13
|
+
/** Meta click ID (`_fbc` cookie, or `getAttribution().fbc`). */
|
|
14
|
+
fbc?: string;
|
|
15
|
+
/** Meta browser ID (`_fbp` cookie, or `getAttribution().fbp`). */
|
|
16
|
+
fbp?: string;
|
|
17
|
+
};
|
|
18
|
+
/** Meta `user_data`: identifiers normalised and SHA-256 hashed (already-hashed values kept), browser signals as they are. */
|
|
19
|
+
export declare const toMetaUserData: (userData: ConversionsApiUserData) => Promise<Record<string, string>>;
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { META_GRAPH_API_VERSION, sendConversionsApiEvent, } from './server/conversionsApi.js';
|
|
2
|
+
export type { ConversionsApiEvent, ConversionsApiOptions, ConversionsApiResult, } from './server/conversionsApi.js';
|
|
3
|
+
export { matchAiAgent, sendAiCrawlerEvent } from './server/aiCrawlers.js';
|
|
4
|
+
export type { AiAgents, AiCrawlerOptions, AiCrawlerReporting, } from './server/aiCrawlers.js';
|
|
5
|
+
export { createTrackHandler } from './server/createTrackHandler.js';
|
|
6
|
+
export type { TrackHandlerOptions } from './server/createTrackHandler.js';
|
|
7
|
+
export { readGa4Cookies } from './server/ga4Cookies.js';
|
|
8
|
+
export { sendMeasurementProtocolEvent } from './server/measurementProtocol.js';
|
|
9
|
+
export type { MeasurementProtocolEvent, MeasurementProtocolOptions, MeasurementProtocolResult, } from './server/measurementProtocol.js';
|
|
10
|
+
export type { ConversionsApiUserData } from './server/normalize.js';
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { a as e, i as t, n, r, t as i } from "./chunks/cookies.js";
|
|
2
|
+
//#region lib/shared/hash.ts
|
|
3
|
+
var a = async (e) => {
|
|
4
|
+
let t = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(e));
|
|
5
|
+
return Array.from(new Uint8Array(t), (e) => e.toString(16).padStart(2, "0")).join("");
|
|
6
|
+
}, o = /^[a-f0-9]{64}$/, s = (e) => o.test(e), c = (e) => e.trim().toLowerCase().replace(/[^\p{L}\p{M}]/gu, ""), l = {
|
|
7
|
+
em: {
|
|
8
|
+
from: "email",
|
|
9
|
+
normalize: (e) => e.trim().toLowerCase()
|
|
10
|
+
},
|
|
11
|
+
ph: {
|
|
12
|
+
from: "phone",
|
|
13
|
+
normalize: (e) => e.replace(/\D/g, "").replace(/^0+/, "")
|
|
14
|
+
},
|
|
15
|
+
fn: {
|
|
16
|
+
from: "firstName",
|
|
17
|
+
normalize: c
|
|
18
|
+
},
|
|
19
|
+
ln: {
|
|
20
|
+
from: "lastName",
|
|
21
|
+
normalize: c
|
|
22
|
+
},
|
|
23
|
+
external_id: {
|
|
24
|
+
from: "externalId",
|
|
25
|
+
normalize: (e) => e.trim()
|
|
26
|
+
}
|
|
27
|
+
}, u = {
|
|
28
|
+
client_ip_address: "clientIpAddress",
|
|
29
|
+
client_user_agent: "clientUserAgent",
|
|
30
|
+
fbc: "fbc",
|
|
31
|
+
fbp: "fbp"
|
|
32
|
+
}, d = async (e) => {
|
|
33
|
+
let t = await Promise.all(Object.entries(l).map(async ([t, { from: n, normalize: r }]) => {
|
|
34
|
+
let i = e[n];
|
|
35
|
+
if (!i) return [];
|
|
36
|
+
if (s(i)) return [[t, i]];
|
|
37
|
+
let o = r(i);
|
|
38
|
+
return o ? [[t, await a(o)]] : [];
|
|
39
|
+
})), n = Object.entries(u).flatMap(([t, n]) => e[n] ? [[t, e[n]]] : []);
|
|
40
|
+
return Object.fromEntries([...t.flat(), ...n]);
|
|
41
|
+
}, f = "v26.0", p = /^\d+$/, m = 1e3, h = (e) => /* @__PURE__ */ TypeError(`[react-marketing-tools] sendConversionsApiEvent: ${e}`), g = ({ pixelId: e, accessToken: t, events: n }) => {
|
|
42
|
+
if (!p.test(e)) throw h(`pixelId must be the numeric pixel ID (received ${JSON.stringify(e)})`);
|
|
43
|
+
if (!t) throw h("accessToken is required");
|
|
44
|
+
if (n.length === 0 || n.length > m) throw h(`send between 1 and ${m} events per request (received ${n.length})`);
|
|
45
|
+
for (let e of n) {
|
|
46
|
+
if (!e.eventName) throw h("every event needs an eventName");
|
|
47
|
+
if ((e.actionSource ?? "website") === "website" && !(e.eventSourceUrl && e.userData.clientUserAgent)) throw h(`website event "${e.eventName}" needs eventSourceUrl and userData.clientUserAgent`);
|
|
48
|
+
}
|
|
49
|
+
}, _ = async ({ event: t, warnings: n }) => {
|
|
50
|
+
let { params: r, redactedKeys: i } = e(t.customData ?? {});
|
|
51
|
+
return i.length > 0 && n.push(`event "${t.eventName}": personal data redacted from customData ${i.join(", ")}`), {
|
|
52
|
+
event_name: t.eventName,
|
|
53
|
+
event_time: Math.floor((t.eventTime ?? /* @__PURE__ */ new Date()).getTime() / 1e3),
|
|
54
|
+
action_source: t.actionSource ?? "website",
|
|
55
|
+
...t.eventId && { event_id: t.eventId },
|
|
56
|
+
...t.eventSourceUrl && { event_source_url: t.eventSourceUrl },
|
|
57
|
+
user_data: await d(t.userData),
|
|
58
|
+
...Object.keys(r).length > 0 && { custom_data: r }
|
|
59
|
+
};
|
|
60
|
+
}, v = async (e) => {
|
|
61
|
+
g(e);
|
|
62
|
+
let { pixelId: t, accessToken: n, events: r, testEventCode: i, graphApiVersion: a = f } = e, o = [], s = await Promise.all(r.map((e) => _({
|
|
63
|
+
event: e,
|
|
64
|
+
warnings: o
|
|
65
|
+
}))), c = new URL(`https://graph.facebook.com/${a}/${t}/events`);
|
|
66
|
+
c.searchParams.set("access_token", n);
|
|
67
|
+
let l = await fetch(c, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "content-type": "application/json" },
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
data: s,
|
|
72
|
+
...i && { test_event_code: i }
|
|
73
|
+
})
|
|
74
|
+
}), u = await l.json().catch(() => void 0);
|
|
75
|
+
return {
|
|
76
|
+
ok: l.ok,
|
|
77
|
+
status: l.status,
|
|
78
|
+
body: u,
|
|
79
|
+
warnings: o
|
|
80
|
+
};
|
|
81
|
+
}, y = {
|
|
82
|
+
global: "https://www.google-analytics.com",
|
|
83
|
+
eu: "https://region1.google-analytics.com"
|
|
84
|
+
}, b = /^G-[A-Z0-9]+$/, x = 25, S = (e) => /* @__PURE__ */ TypeError(`[react-marketing-tools] sendMeasurementProtocolEvent: ${e}`), C = ({ measurementId: e, apiSecret: t, clientId: i, userId: a, events: o }) => {
|
|
85
|
+
if (!b.test(e)) throw S(`measurementId must look like "G-XXXXXXX" (received ${JSON.stringify(e)})`);
|
|
86
|
+
if (!t) throw S("apiSecret is required");
|
|
87
|
+
if (!i) throw S("clientId is required; read it from the _ga cookie with readGa4Cookies()");
|
|
88
|
+
if (a !== void 0 && (!a || n(a))) throw S("userId must be your own identifier, not personal data such as an email address");
|
|
89
|
+
if (o.length === 0 || o.length > x) throw S(`send between 1 and ${x} events per request (received ${o.length})`);
|
|
90
|
+
for (let { name: e } of o) {
|
|
91
|
+
let t = r(e);
|
|
92
|
+
if (t) throw S(t);
|
|
93
|
+
}
|
|
94
|
+
}, w = ({ event: { name: n, params: r = {} }, sessionId: i, warnings: a }) => {
|
|
95
|
+
for (let e of t(r)) a.push(`event "${n}" ${e}`);
|
|
96
|
+
let { params: o, redactedKeys: s } = e(r);
|
|
97
|
+
return s.length > 0 && a.push(`event "${n}": personal data redacted from ${s.join(", ")}`), {
|
|
98
|
+
name: n,
|
|
99
|
+
params: {
|
|
100
|
+
...i && { session_id: i },
|
|
101
|
+
engagement_time_msec: 1,
|
|
102
|
+
...o
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}, T = ({ adUserData: e, adPersonalization: t }) => ({
|
|
106
|
+
...e && { ad_user_data: e.toUpperCase() },
|
|
107
|
+
...t && { ad_personalization: t.toUpperCase() }
|
|
108
|
+
}), E = async (e) => {
|
|
109
|
+
C(e);
|
|
110
|
+
let { measurementId: t, apiSecret: n, clientId: r, sessionId: i, userId: a, events: o, consent: s, validate: c = !1, region: l = "global" } = e, u = [], d = {
|
|
111
|
+
client_id: r,
|
|
112
|
+
...a && { user_id: a },
|
|
113
|
+
...s && { consent: T(s) },
|
|
114
|
+
events: o.map((e) => w({
|
|
115
|
+
event: e,
|
|
116
|
+
sessionId: i,
|
|
117
|
+
warnings: u
|
|
118
|
+
}))
|
|
119
|
+
}, f = new URL(c ? "/debug/mp/collect" : "/mp/collect", y[l]);
|
|
120
|
+
f.searchParams.set("measurement_id", t), f.searchParams.set("api_secret", n);
|
|
121
|
+
let p = await fetch(f, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify(d)
|
|
125
|
+
});
|
|
126
|
+
if (!c) return {
|
|
127
|
+
ok: p.ok,
|
|
128
|
+
status: p.status,
|
|
129
|
+
warnings: u
|
|
130
|
+
};
|
|
131
|
+
let { validationMessages: m = [] } = await p.json().catch(() => ({}));
|
|
132
|
+
return {
|
|
133
|
+
ok: p.ok && m.length === 0,
|
|
134
|
+
status: p.status,
|
|
135
|
+
warnings: u,
|
|
136
|
+
validationMessages: m
|
|
137
|
+
};
|
|
138
|
+
}, D = ({ userAgent: e, agents: t }) => {
|
|
139
|
+
let n = e.toLowerCase();
|
|
140
|
+
return Object.entries(t).find(([, e]) => e.some((e) => n.includes(e.toLowerCase())))?.[0];
|
|
141
|
+
}, O = (e) => /* @__PURE__ */ TypeError(`[react-marketing-tools] sendAiCrawlerEvent: ${e}`), k = ["traffic_type", "ai_agent"], A = async (e) => {
|
|
142
|
+
let { agent: t, keepSeparate: n, events: r, ...i } = e;
|
|
143
|
+
if (!t) throw O("agent is required");
|
|
144
|
+
let a = n === "separate-property" ? "ai_crawler" : n?.trafficType;
|
|
145
|
+
if (!a) throw O("keepSeparate is required: 'separate-property' when measurementId is a crawler-only property, or { trafficType } matching a GA4 data filter. Crawler hits must never land in a visitor property untagged.");
|
|
146
|
+
let o = r.flatMap((e) => k.filter((t) => e.params?.[t] !== void 0).map((t) => `event "${e.name}" param "${t}" was replaced: sendAiCrawlerEvent sets it`)), s = await E({
|
|
147
|
+
...i,
|
|
148
|
+
events: r.map((e) => ({
|
|
149
|
+
...e,
|
|
150
|
+
params: {
|
|
151
|
+
...e.params,
|
|
152
|
+
traffic_type: a,
|
|
153
|
+
ai_agent: t
|
|
154
|
+
}
|
|
155
|
+
}))
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
...s,
|
|
159
|
+
warnings: [...o, ...s.warnings]
|
|
160
|
+
};
|
|
161
|
+
}, j = 16384, M = /^\d+$/, N = [
|
|
162
|
+
"externalId",
|
|
163
|
+
"email",
|
|
164
|
+
"phone",
|
|
165
|
+
"firstName",
|
|
166
|
+
"lastName",
|
|
167
|
+
"fbc",
|
|
168
|
+
"fbp"
|
|
169
|
+
], P = (e) => /* @__PURE__ */ TypeError(`[react-marketing-tools] createTrackHandler: ${e}`), F = ({ allowedOrigins: e, meta: t }) => {
|
|
170
|
+
if (e.length === 0) throw P("allowedOrigins needs at least one origin, e.g. 'https://shop.example.com'");
|
|
171
|
+
for (let t of e) if (!URL.canParse(t) || new URL(t).origin !== t) throw P(`allowedOrigins entries must be origins such as 'https://shop.example.com', without a path or trailing slash (received ${JSON.stringify(t)})`);
|
|
172
|
+
if (!M.test(t.pixelId)) throw P(`meta.pixelId must be the numeric pixel ID (received ${JSON.stringify(t.pixelId)})`);
|
|
173
|
+
if (!t.accessToken) throw P("meta.accessToken is required");
|
|
174
|
+
}, I = (e, t) => new Response(t, { status: e }), L = async (e) => {
|
|
175
|
+
if (Number(e.headers.get("content-length")) > j) return;
|
|
176
|
+
if (!e.body) return "";
|
|
177
|
+
let t = e.body.getReader(), n = new TextDecoder(), r = "", i = 0;
|
|
178
|
+
for (;;) {
|
|
179
|
+
let { done: e, value: a } = await t.read();
|
|
180
|
+
if (e) return r + n.decode();
|
|
181
|
+
if (i += a.byteLength, i > j) {
|
|
182
|
+
await t.cancel();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
r += n.decode(a, { stream: !0 });
|
|
186
|
+
}
|
|
187
|
+
}, R = (e) => typeof e == "object" && !!e && !Array.isArray(e), z = (e) => typeof e == "string" && e.length > 0, B = (e) => typeof e == "string" && URL.canParse(e) && ["http:", "https:"].includes(new URL(e).protocol), V = (e) => {
|
|
188
|
+
let t;
|
|
189
|
+
try {
|
|
190
|
+
t = JSON.parse(e);
|
|
191
|
+
} catch {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!R(t) || t.v !== 1) return;
|
|
195
|
+
let { eventName: n, eventId: r, customData: i, eventSourceUrl: a, consent: o, userData: s } = t;
|
|
196
|
+
if (z(n) && z(r) && R(i) && B(a) && R(o) && (o.adUserData === "granted" || o.adUserData === "denied") && R(s) && !N.some((e) => s[e] !== void 0 && typeof s[e] != "string")) return {
|
|
197
|
+
v: 1,
|
|
198
|
+
eventName: n,
|
|
199
|
+
eventId: r,
|
|
200
|
+
customData: i,
|
|
201
|
+
eventSourceUrl: a,
|
|
202
|
+
consent: { adUserData: o.adUserData },
|
|
203
|
+
userData: Object.fromEntries(N.filter((e) => s[e] !== void 0).map((e) => [e, s[e]]))
|
|
204
|
+
};
|
|
205
|
+
}, H = (e) => {
|
|
206
|
+
F(e);
|
|
207
|
+
let { meta: t, onError: n = console.error } = e, r = new Set(e.allowedOrigins);
|
|
208
|
+
return async (e) => {
|
|
209
|
+
if (e.method !== "POST") return new Response("Use POST", {
|
|
210
|
+
status: 405,
|
|
211
|
+
headers: { allow: "POST" }
|
|
212
|
+
});
|
|
213
|
+
let i = e.headers.get("origin");
|
|
214
|
+
if (!i || !r.has(i)) return I(403, "Origin not allowed");
|
|
215
|
+
let a = await L(e);
|
|
216
|
+
if (a === void 0) return I(413, "Body too large");
|
|
217
|
+
let o = V(a);
|
|
218
|
+
if (!o) return I(400, "Invalid event");
|
|
219
|
+
let s = e.headers.get("user-agent");
|
|
220
|
+
if (!s) return I(400, "Missing User-Agent");
|
|
221
|
+
if (o.consent.adUserData !== "granted") return I(204);
|
|
222
|
+
try {
|
|
223
|
+
let r = await v({
|
|
224
|
+
...t,
|
|
225
|
+
events: [{
|
|
226
|
+
eventName: o.eventName,
|
|
227
|
+
eventId: o.eventId,
|
|
228
|
+
eventSourceUrl: o.eventSourceUrl,
|
|
229
|
+
userData: {
|
|
230
|
+
...o.userData,
|
|
231
|
+
clientIpAddress: e.headers.get("x-forwarded-for")?.split(",")[0].trim(),
|
|
232
|
+
clientUserAgent: s
|
|
233
|
+
},
|
|
234
|
+
customData: o.customData
|
|
235
|
+
}]
|
|
236
|
+
});
|
|
237
|
+
if (r.ok) return I(204);
|
|
238
|
+
n(Error(`[react-marketing-tools] createTrackHandler: Meta rejected "${o.eventName}" (HTTP ${r.status})`, { cause: r.body }));
|
|
239
|
+
} catch (e) {
|
|
240
|
+
n(Error(`[react-marketing-tools] createTrackHandler: could not send "${o.eventName}" to Meta`, { cause: e }));
|
|
241
|
+
}
|
|
242
|
+
return I(502, "Meta did not accept the event");
|
|
243
|
+
};
|
|
244
|
+
}, U = ({ cookieHeader: e, measurementId: t }) => {
|
|
245
|
+
let n = /^GA\d\.\d\.(\d+\.\d+)$/.exec(i(e, "_ga") ?? "")?.[1], r = i(e, `_ga_${t.replace(/^G-/, "")}`) ?? "", a = /^GS1\.\d\.(\d+)\./.exec(r)?.[1] ?? /^GS2\.\d\.s(\d+)/.exec(r)?.[1];
|
|
246
|
+
return {
|
|
247
|
+
...n && { clientId: n },
|
|
248
|
+
...a && { sessionId: a }
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
//#endregion
|
|
252
|
+
export { f as META_GRAPH_API_VERSION, H as createTrackHandler, D as matchAiAgent, U as readGa4Cookies, A as sendAiCrawlerEvent, v as sendConversionsApiEvent, E as sendMeasurementProtocolEvent };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** SHA-256 of a UTF-8 string, as lowercase hex. Uses Web Crypto, so it runs in browsers, Node.js and edge runtimes. */
|
|
2
|
+
export declare const sha256Hex: (value: string) => Promise<string>;
|
|
3
|
+
/** True for a value that is already a SHA-256 hex digest, so it isn't hashed twice. */
|
|
4
|
+
export declare const isSha256Hex: (value: string) => boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const randomUuid: () => string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Analytics } from './core/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reports Core Web Vitals (LCP, INP and CLS) as events, the way the `web-vitals` library recommends for GA4. Install it
|
|
4
|
+
* first: `npm install web-vitals@^6`. It's downloaded when this is first called, and does nothing outside the browser.
|
|
5
|
+
*
|
|
6
|
+
* The events reach GA4 and Tag Manager, never the Meta Pixel or the relay.
|
|
7
|
+
*/
|
|
8
|
+
export declare const trackWebVitals: (analytics: Pick<Analytics, 'track'>) => Promise<void>;
|