tracklane 0.1.1 → 0.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/README.md +28 -5
- package/dist/browser-DmJeRMFB.d.ts +552 -0
- package/dist/browser.d.ts +2 -2
- package/dist/browser.js +204 -6
- package/dist/browser.js.map +1 -1
- package/dist/index.d.ts +1 -3
- package/dist/server.d.ts +2 -2
- package/dist/server.js +212 -1
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
- package/dist/browser-usMUxJhU.d.ts +0 -126
- package/dist/server-DRda44Tb.d.ts +0 -166
- package/dist/vocabulary-fzM9ZuE1.d.ts +0 -129
package/dist/browser.js
CHANGED
|
@@ -10,7 +10,7 @@ function gtagWindow() {
|
|
|
10
10
|
* library replicates that rather than swallowing it: a silent no-op is how a
|
|
11
11
|
* host ends up believing it is measuring something.
|
|
12
12
|
*/
|
|
13
|
-
function push(...args) {
|
|
13
|
+
function push$1(...args) {
|
|
14
14
|
const gtag = gtagWindow()?.gtag;
|
|
15
15
|
if (!gtag) throw new Error("ga4: window.gtag is not defined. Add Google's tag to the page before creating tracking.");
|
|
16
16
|
gtag(...args);
|
|
@@ -51,17 +51,215 @@ function ga4(measurementId, config = {}) {
|
|
|
51
51
|
default: "passthrough",
|
|
52
52
|
...events ? { events } : {},
|
|
53
53
|
track(name, data, _options) {
|
|
54
|
-
push("event", name, {
|
|
54
|
+
push$1("event", name, {
|
|
55
55
|
...data,
|
|
56
56
|
send_to: measurementId
|
|
57
57
|
});
|
|
58
58
|
},
|
|
59
59
|
identify(user, traits) {
|
|
60
|
-
if (user.userId !== void 0) push("set", { user_id: user.userId });
|
|
61
|
-
if (traits) push("set", "user_properties", traits);
|
|
60
|
+
if (user.userId !== void 0) push$1("set", { user_id: user.userId });
|
|
61
|
+
if (traits) push$1("set", "user_properties", traits);
|
|
62
62
|
},
|
|
63
63
|
consent(command, state) {
|
|
64
|
-
push("consent", command, state);
|
|
64
|
+
push$1("consent", command, state);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/providers/meta.browser.ts
|
|
70
|
+
/**
|
|
71
|
+
* The names Meta accepts through `fbq('track')`. Anything else is a custom
|
|
72
|
+
* event and goes through `trackCustom`, which is a different command rather
|
|
73
|
+
* than a different argument.
|
|
74
|
+
*
|
|
75
|
+
* Seventeen standard events plus `PageView`, which Meta documents separately
|
|
76
|
+
* from the seventeen and which the base snippet fires by itself.
|
|
77
|
+
*/
|
|
78
|
+
const STANDARD_EVENTS = [
|
|
79
|
+
"AddPaymentInfo",
|
|
80
|
+
"AddToCart",
|
|
81
|
+
"AddToWishlist",
|
|
82
|
+
"CompleteRegistration",
|
|
83
|
+
"Contact",
|
|
84
|
+
"CustomizeProduct",
|
|
85
|
+
"Donate",
|
|
86
|
+
"FindLocation",
|
|
87
|
+
"InitiateCheckout",
|
|
88
|
+
"Lead",
|
|
89
|
+
"PageView",
|
|
90
|
+
"Purchase",
|
|
91
|
+
"Schedule",
|
|
92
|
+
"Search",
|
|
93
|
+
"StartTrial",
|
|
94
|
+
"SubmitApplication",
|
|
95
|
+
"Subscribe",
|
|
96
|
+
"ViewContent"
|
|
97
|
+
];
|
|
98
|
+
/**
|
|
99
|
+
* How Meta spells the canonical vocabulary, which is GA4's.
|
|
100
|
+
*
|
|
101
|
+
* Only canonical names appear here. Meta has eight more standard events with
|
|
102
|
+
* no GA4 counterpart (`Subscribe`, `StartTrial`, `Schedule`, `Contact`,
|
|
103
|
+
* `Donate`, `FindLocation`, `CustomizeProduct`, `SubmitApplication`), and
|
|
104
|
+
* inventing canonical names for them would make this library the author of a
|
|
105
|
+
* vocabulary it deliberately borrows. A host that fires those maps its own
|
|
106
|
+
* event name to them through `events`.
|
|
107
|
+
*
|
|
108
|
+
* A canonical event with no standard counterpart is **not** dropped: it goes
|
|
109
|
+
* to `trackCustom` under its own name, because by hand that event would have
|
|
110
|
+
* been sent.
|
|
111
|
+
*/
|
|
112
|
+
const CANONICAL_EVENTS = {
|
|
113
|
+
page_view: "PageView",
|
|
114
|
+
view_item: "ViewContent",
|
|
115
|
+
search: "Search",
|
|
116
|
+
add_to_cart: "AddToCart",
|
|
117
|
+
add_to_wishlist: "AddToWishlist",
|
|
118
|
+
begin_checkout: "InitiateCheckout",
|
|
119
|
+
add_payment_info: "AddPaymentInfo",
|
|
120
|
+
purchase: "Purchase",
|
|
121
|
+
sign_up: "CompleteRegistration",
|
|
122
|
+
generate_lead: "Lead"
|
|
123
|
+
};
|
|
124
|
+
function fbqWindow() {
|
|
125
|
+
return typeof window === "undefined" ? void 0 : window;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Sends a command, or fails the way a hand-written call would.
|
|
129
|
+
*
|
|
130
|
+
* Writing `fbq(…)` on a page without Meta's snippet throws, and this library
|
|
131
|
+
* replicates that rather than swallowing it: a silent no-op is how a host
|
|
132
|
+
* ends up believing it is measuring something.
|
|
133
|
+
*/
|
|
134
|
+
function push(...args) {
|
|
135
|
+
const fbq = fbqWindow()?.fbq;
|
|
136
|
+
if (!fbq) throw new Error("meta: window.fbq is not defined. Add Meta's pixel to the page before creating tracking.");
|
|
137
|
+
fbq(...args);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* GA4's `items` become Meta's `contents`, which is a closed shape: `id`,
|
|
141
|
+
* `quantity`, `item_price` and `delivery_category`, and nothing else.
|
|
142
|
+
*
|
|
143
|
+
* The rest of a GA4 item, `item_name` and `item_brand`, is dropped rather than
|
|
144
|
+
* forwarded, because a key Meta does not read inside `contents` looks mapped
|
|
145
|
+
* and arrives nowhere. That is the failure mode this project keeps finding.
|
|
146
|
+
*/
|
|
147
|
+
function contents(items) {
|
|
148
|
+
return items.map((item) => {
|
|
149
|
+
const content = {};
|
|
150
|
+
if (item.item_id !== void 0) content.id = item.item_id;
|
|
151
|
+
if (item.quantity !== void 0) content.quantity = item.quantity;
|
|
152
|
+
if (item.price !== void 0) content.item_price = item.price;
|
|
153
|
+
return content;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* `value` and `currency` are spelled identically by both vendors, so they
|
|
158
|
+
* pass through untouched. Everything the host named itself passes through
|
|
159
|
+
* too: Meta keeps unknown keys as custom properties, which is exactly what a
|
|
160
|
+
* hand-written call would have produced.
|
|
161
|
+
*
|
|
162
|
+
* `transaction_id` stays under its own name here. Meta's pixel documents no
|
|
163
|
+
* order id among its object properties. `order_id` exists on the Conversions
|
|
164
|
+
* API and not on this surface, so translating it would be inventing a slot.
|
|
165
|
+
*/
|
|
166
|
+
function pixelParams(data) {
|
|
167
|
+
const { items, ...rest } = data;
|
|
168
|
+
const params = { ...rest };
|
|
169
|
+
if (items) params.contents = contents(items);
|
|
170
|
+
return params;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Meta's pixel has one consent switch where this library carries Google's
|
|
174
|
+
* four signals, so the four are collapsed into it.
|
|
175
|
+
*
|
|
176
|
+
* The collapse is the strictest declared answer: any `denied` produces
|
|
177
|
+
* `revoke`, and `grant` needs every declared signal to be `granted`. There is
|
|
178
|
+
* no field to partition, so this is the only reduction that cannot widen a
|
|
179
|
+
* denial, and widening is the one thing a projection must never do.
|
|
180
|
+
*
|
|
181
|
+
* A host that wants Meta to follow only its advertising category, and not its
|
|
182
|
+
* analytics one, decides that before `createTracking` by leaving this
|
|
183
|
+
* provider out, which is what `@tracklane/consent` exists to do.
|
|
184
|
+
*/
|
|
185
|
+
function collapse(state) {
|
|
186
|
+
const signals = [
|
|
187
|
+
state.ad_storage,
|
|
188
|
+
state.analytics_storage,
|
|
189
|
+
state.ad_user_data,
|
|
190
|
+
state.ad_personalization
|
|
191
|
+
].filter((signal) => signal !== void 0);
|
|
192
|
+
if (signals.length === 0) return void 0;
|
|
193
|
+
return signals.every((signal) => signal === "granted") ? "grant" : "revoke";
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The Meta pixel in the browser, through `fbq`.
|
|
197
|
+
*
|
|
198
|
+
* **This provider does not load Meta's pixel.** The base code belongs on the
|
|
199
|
+
* page, installed the way
|
|
200
|
+
* [Meta documents it](https://developers.facebook.com/docs/meta-pixel/get-started):
|
|
201
|
+
* a snippet in the HTML, or a tag manager. This library talks to the pixel
|
|
202
|
+
* that is already there; it never injects a third-party script and never
|
|
203
|
+
* issues `fbq('init')`.
|
|
204
|
+
*
|
|
205
|
+
* Three consequences follow from that, and all three are Meta's rules rather
|
|
206
|
+
* than ours:
|
|
207
|
+
*
|
|
208
|
+
* - **There is no `identify` here.** Meta accepts advanced matching only in
|
|
209
|
+
* the base code ("be sure to place advanced matching parameters in the
|
|
210
|
+
* pixel base code or the values will not be treated as manual advanced
|
|
211
|
+
* matching values"), and the base code is yours. Put `em`, `ph`, `fn`, `ln`
|
|
212
|
+
* in your own `fbq('init')`, where the pixel normalises and hashes them for
|
|
213
|
+
* you. On the server, where there is no snippet, the same identity travels
|
|
214
|
+
* in `context.user` and is hashed by the adapter.
|
|
215
|
+
* - **The initial consent declaration is not ours.** Meta wants
|
|
216
|
+
* `fbq('consent', 'revoke')` before `init`, and by the time you hold what
|
|
217
|
+
* `createTracking` returns that moment has passed. Ours is every
|
|
218
|
+
* declaration after it, through `consent()`.
|
|
219
|
+
* - **Limited Data Use is not ours either.** `fbq('dataProcessingOptions', …)`
|
|
220
|
+
* is documented as running before `init`. It belongs in your snippet. The
|
|
221
|
+
* server half, which has no snippet, carries it on its factory.
|
|
222
|
+
*
|
|
223
|
+
* **There is no pixel id here**, and that is deliberate rather than an
|
|
224
|
+
* omission. `fbq('track')` reaches every pixel the page initialised, so an id
|
|
225
|
+
* passed in would address nothing and the parameter would promise something
|
|
226
|
+
* this adapter cannot deliver. Which pixels exist is declared by your snippet,
|
|
227
|
+
* in `fbq('init')`.
|
|
228
|
+
*
|
|
229
|
+
* @param config - Event name overrides, merged over the built-in map.
|
|
230
|
+
* @returns A provider for `createTracking` from `tracklane/browser`.
|
|
231
|
+
*
|
|
232
|
+
* @example
|
|
233
|
+
* ```ts
|
|
234
|
+
* import { createTracking, meta } from 'tracklane/browser';
|
|
235
|
+
*
|
|
236
|
+
* // Meta's own base code is already in the page, and it named the pixel.
|
|
237
|
+
* const { track } = createTracking({ providers: [meta()] });
|
|
238
|
+
*
|
|
239
|
+
* // fbq('track', 'Purchase', { value: 49.9, currency: 'BRL' }, { eventID: 'T-1' })
|
|
240
|
+
* track('purchase', { value: 49.9, currency: 'BRL' }, { dedupId: 'T-1' });
|
|
241
|
+
* ```
|
|
242
|
+
*/
|
|
243
|
+
function meta(config = {}) {
|
|
244
|
+
return {
|
|
245
|
+
name: "meta",
|
|
246
|
+
default: "passthrough",
|
|
247
|
+
events: {
|
|
248
|
+
...CANONICAL_EVENTS,
|
|
249
|
+
...config.events
|
|
250
|
+
},
|
|
251
|
+
track(name, data, options) {
|
|
252
|
+
const args = [
|
|
253
|
+
STANDARD_EVENTS.includes(name) ? "track" : "trackCustom",
|
|
254
|
+
name,
|
|
255
|
+
pixelParams(data)
|
|
256
|
+
];
|
|
257
|
+
if (options.dedupId !== void 0) args.push({ eventID: options.dedupId });
|
|
258
|
+
push(...args);
|
|
259
|
+
},
|
|
260
|
+
consent(_command, state) {
|
|
261
|
+
const answer = collapse(state);
|
|
262
|
+
if (answer) push("consent", answer);
|
|
65
263
|
}
|
|
66
264
|
};
|
|
67
265
|
}
|
|
@@ -136,6 +334,6 @@ function createTracking(options) {
|
|
|
136
334
|
};
|
|
137
335
|
}
|
|
138
336
|
//#endregion
|
|
139
|
-
export { createTracking, ga4 };
|
|
337
|
+
export { createTracking, ga4, meta };
|
|
140
338
|
|
|
141
339
|
//# sourceMappingURL=browser.js.map
|
package/dist/browser.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser.js","names":[],"sources":["../src/providers/ga4.browser.ts","../src/browser.ts"],"sourcesContent":["import type { BrowserProvider, TrackOptions } from '../browser.js';\nimport type { ConsentState, EventBinding, EventData, UserData } from '../vocabulary.js';\n\ninterface GtagWindow {\n gtag?: (...args: unknown[]) => void;\n}\n\n/** Optional configuration for the GA4 browser provider. */\nexport interface Ga4BrowserConfig {\n /**\n * How this property spells each canonical event. Rarely needed: the\n * canonical vocabulary already is GA4's.\n */\n events?: Record<string, EventBinding<string>>;\n}\n\n// Server rendering reaches this module with no browser at all, where\n// `window` is a ReferenceError rather than `undefined`.\nfunction gtagWindow(): GtagWindow | undefined {\n return typeof window === 'undefined' ? undefined : (window as unknown as GtagWindow);\n}\n\n/**\n * Sends a command, or fails the way a hand-written call would.\n *\n * Writing `gtag(…)` on a page without Google's snippet throws, and this\n * library replicates that rather than swallowing it: a silent no-op is how a\n * host ends up believing it is measuring something.\n */\nfunction push(...args: unknown[]): void {\n const gtag = gtagWindow()?.gtag;\n\n if (!gtag) {\n throw new Error(\n \"ga4: window.gtag is not defined. Add Google's tag to the page before creating tracking.\",\n );\n }\n\n gtag(...args);\n}\n\n/**\n * Google Analytics 4 in the browser, through `gtag.js`.\n *\n * **This provider does not load Google's tag.** The tag belongs on the page,\n * installed the way Google documents it: a snippet in the HTML, or a tag\n * manager. This library talks to the tag that is already there; it never\n * injects a third-party script into your page, and it never issues the\n * property configuration that would emit a second page view.\n *\n * Which also means the initial consent declaration is not ours to make:\n * Google requires it before the property is configured, and the snippet that\n * configures the property is yours. Use {@link ConsentState} through\n * `consent()` for changes after that.\n *\n * @param measurementId - The `G-` id of the data stream, matching the tag on\n * the page.\n * @param config - Rarely needed event name overrides.\n * @returns A provider for `createTracking` from `tracklane/browser`.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/browser';\n *\n * // Google's own snippet is already in the page.\n * const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });\n *\n * track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });\n * ```\n */\nexport function ga4(measurementId: string, config: Ga4BrowserConfig = {}): BrowserProvider {\n const { events } = config;\n\n return {\n name: 'ga4',\n // The canonical names already are GA4's, so every event passes through\n // under its own name unless the host says otherwise.\n default: 'passthrough',\n ...(events ? { events } : {}),\n\n track(name: string, data: EventData, _options: TrackOptions): void {\n // GA4 documents no browser-to-server deduplication, so `dedupId` maps\n // to nothing here. That is the exact translation of \"this concept does\n // not exist in this vendor\". Inventing a param would imply a merge\n // the platform never performs.\n //\n // `send_to` is written after the host's data on purpose: without it,\n // gtag sends to every configured property on the page, so the\n // addressing is the adapter's to own and not the host's to override.\n push('event', name, { ...data, send_to: measurementId });\n },\n\n identify(user: UserData, traits?: Record<string, unknown>): void {\n // GA4 documents no browser slot for email, phone or names. They map to\n // nothing, which is what \"this concept does not exist here\" means.\n if (user.userId !== undefined) push('set', { user_id: user.userId });\n if (traits) push('set', 'user_properties', traits);\n },\n\n consent(command: 'default' | 'update', state: ConsentState): void {\n // Verbatim: the library's consent vocabulary is Google's own spelling,\n // so there is nothing to translate and no chance to expand a signal\n // the host did not declare.\n push('consent', command, state);\n },\n };\n}\n","import { assertUniqueProviders, makeReporter, messageOf, resolveEvent } from './dispatch.js';\n\nexport { type Ga4BrowserConfig, ga4 } from './providers/ga4.browser.js';\n\nimport type {\n ConsentState,\n EventBinding,\n EventData,\n ProviderDefault,\n TrackingError,\n UserData,\n} from './vocabulary.js';\n\n/**\n * Everything the browser needs beyond the event itself.\n *\n * There is one field, and that is the point: in the browser each vendor tag\n * reads its own cookies and holds its own session, so the only thing left\n * for the host to supply is the value that ties this hit to a server-side\n * one.\n */\nexport interface TrackOptions {\n /**\n * The value that lets a vendor recognise this hit and a server-side hit as\n * one event. The library never invents it: the browser and the server do\n * not share a process, so only a value the host already owns (an order id)\n * can match on both sides.\n */\n dedupId?: string;\n}\n\n/**\n * A destination for browser events.\n *\n * This is the whole contract. A provider written outside this package\n * implements it exactly as the providers shipped here do.\n */\nexport interface BrowserProvider<Target = string> {\n /** The vendor's fixed name. Registering one twice is an error. */\n name: string;\n /** What happens to a canonical event this vendor has no binding for. */\n default: ProviderDefault;\n /** How this vendor spells each canonical event. */\n events?: Record<string, EventBinding<Target>>;\n /** Loads the vendor tag. Runs once, at creation. */\n install?(): void | Promise<void>;\n /** Sends one event to this vendor. Throwing here reaches `onError`, not the caller. */\n track(name: Target, data: EventData, options: TrackOptions): void;\n /** Absent where the vendor keeps no user of its own. */\n identify?(user: UserData, traits?: Record<string, unknown>): void;\n /** Absent where the vendor documents no consent command. */\n consent?(command: 'default' | 'update', state: ConsentState): void;\n}\n\n/** Options accepted by {@link createTracking}. */\nexport interface BrowserTrackingOptions {\n /** The vendors to send every event to. Registering the same `name` twice throws. */\n providers: readonly BrowserProvider[];\n /**\n * The one diagnostic channel. Without it the library is silent, exactly as\n * a hand-written vendor call is.\n */\n onError?: (error: TrackingError) => void;\n}\n\n/** What {@link createTracking} returns. */\nexport interface BrowserTracking {\n /** Sends one event to every configured vendor. Never throws. */\n track(name: string, data?: EventData, options?: TrackOptions): void;\n /** Forwards the user to the vendors that hold one, and to nobody else. */\n identify(user: UserData, traits?: Record<string, unknown>): void;\n /**\n * Forwards a consent declaration to every vendor that documents a command\n * for it. It never decides, stores or withholds anything.\n */\n consent(command: 'default' | 'update', state: ConsentState): void;\n}\n\n/**\n * Creates the browser tracking surface.\n *\n * Providers install eagerly, which is what pasting a vendor snippet does and\n * what lets each vendor record its own initial page view. Every vendor\n * shipped here queues its own commands until its script lands, so nothing is\n * lost to loading and this library needs no queue of its own.\n *\n * @param options - The providers to send to, and an optional error channel.\n * @returns The `track`, `identify` and `consent` calls.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/browser';\n *\n * const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });\n * track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });\n * ```\n */\nexport function createTracking(options: BrowserTrackingOptions): BrowserTracking {\n const { providers } = options;\n assertUniqueProviders(providers);\n\n const report = makeReporter(options.onError);\n\n const fail = (provider: string, event: string, cause: unknown): void => {\n report({ provider, event, severity: 'error', message: messageOf(cause), cause });\n };\n\n for (const provider of providers) {\n try {\n const installing = provider.install?.();\n // An async loader that rejects would otherwise become an unhandled\n // rejection, the silent failure this channel exists to prevent.\n void Promise.resolve(installing).catch((cause: unknown) => {\n fail(provider.name, 'install', cause);\n });\n } catch (cause) {\n fail(provider.name, 'install', cause);\n }\n }\n\n return {\n track(name, data = {}, trackOptions = {}) {\n for (const provider of providers) {\n const target = resolveEvent(provider, name);\n if (target === undefined) continue;\n\n try {\n provider.track(target, data, trackOptions);\n } catch (cause) {\n fail(provider.name, name, cause);\n }\n }\n },\n\n identify(user, traits) {\n for (const provider of providers) {\n try {\n provider.identify?.(user, traits);\n } catch (cause) {\n fail(provider.name, 'identify', cause);\n }\n }\n },\n\n consent(command, state) {\n for (const provider of providers) {\n try {\n provider.consent?.(command, state);\n } catch (cause) {\n fail(provider.name, 'consent', cause);\n }\n }\n },\n };\n}\n"],"mappings":";;AAkBA,SAAS,aAAqC;CAC5C,OAAO,OAAO,WAAW,cAAc,KAAA,IAAa;AACtD;;;;;;;;AASA,SAAS,KAAK,GAAG,MAAuB;CACtC,MAAM,OAAO,WAAW,CAAC,EAAE;CAE3B,IAAI,CAAC,MACH,MAAM,IAAI,MACR,yFACF;CAGF,KAAK,GAAG,IAAI;AACd;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,SAAgB,IAAI,eAAuB,SAA2B,CAAC,GAAoB;CACzF,MAAM,EAAE,WAAW;CAEnB,OAAO;EACL,MAAM;EAGN,SAAS;EACT,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;EAE3B,MAAM,MAAc,MAAiB,UAA8B;GASjE,KAAK,SAAS,MAAM;IAAE,GAAG;IAAM,SAAS;GAAc,CAAC;EACzD;EAEA,SAAS,MAAgB,QAAwC;GAG/D,IAAI,KAAK,WAAW,KAAA,GAAW,KAAK,OAAO,EAAE,SAAS,KAAK,OAAO,CAAC;GACnE,IAAI,QAAQ,KAAK,OAAO,mBAAmB,MAAM;EACnD;EAEA,QAAQ,SAA+B,OAA2B;GAIhE,KAAK,WAAW,SAAS,KAAK;EAChC;CACF;AACF;;;;;;;;;;;;;;;;;;;;;;ACTA,SAAgB,eAAe,SAAkD;CAC/E,MAAM,EAAE,cAAc;CACtB,sBAAsB,SAAS;CAE/B,MAAM,SAAS,aAAa,QAAQ,OAAO;CAE3C,MAAM,QAAQ,UAAkB,OAAe,UAAyB;EACtE,OAAO;GAAE;GAAU;GAAO,UAAU;GAAS,SAAS,UAAU,KAAK;GAAG;EAAM,CAAC;CACjF;CAEA,KAAK,MAAM,YAAY,WACrB,IAAI;EACF,MAAM,aAAa,SAAS,UAAU;EAGtC,QAAa,QAAQ,UAAU,CAAC,CAAC,OAAO,UAAmB;GACzD,KAAK,SAAS,MAAM,WAAW,KAAK;EACtC,CAAC;CACH,SAAS,OAAO;EACd,KAAK,SAAS,MAAM,WAAW,KAAK;CACtC;CAGF,OAAO;EACL,MAAM,MAAM,OAAO,CAAC,GAAG,eAAe,CAAC,GAAG;GACxC,KAAK,MAAM,YAAY,WAAW;IAChC,MAAM,SAAS,aAAa,UAAU,IAAI;IAC1C,IAAI,WAAW,KAAA,GAAW;IAE1B,IAAI;KACF,SAAS,MAAM,QAAQ,MAAM,YAAY;IAC3C,SAAS,OAAO;KACd,KAAK,SAAS,MAAM,MAAM,KAAK;IACjC;GACF;EACF;EAEA,SAAS,MAAM,QAAQ;GACrB,KAAK,MAAM,YAAY,WACrB,IAAI;IACF,SAAS,WAAW,MAAM,MAAM;GAClC,SAAS,OAAO;IACd,KAAK,SAAS,MAAM,YAAY,KAAK;GACvC;EAEJ;EAEA,QAAQ,SAAS,OAAO;GACtB,KAAK,MAAM,YAAY,WACrB,IAAI;IACF,SAAS,UAAU,SAAS,KAAK;GACnC,SAAS,OAAO;IACd,KAAK,SAAS,MAAM,WAAW,KAAK;GACtC;EAEJ;CACF;AACF"}
|
|
1
|
+
{"version":3,"file":"browser.js","names":["push"],"sources":["../src/providers/ga4.browser.ts","../src/providers/meta.browser.ts","../src/browser.ts"],"sourcesContent":["// Through the public entry point, exactly as a provider written elsewhere\n// would import it. A provider that needs something not exported here is\n// telling us the contract is incomplete, which is a conversation rather than\n// a reason to reach inside.\nimport type {\n BrowserProvider,\n ConsentState,\n EventBinding,\n EventData,\n TrackOptions,\n UserData,\n} from '../index.js';\n\ninterface GtagWindow {\n gtag?: (...args: unknown[]) => void;\n}\n\n/** Optional configuration for the GA4 browser provider. */\nexport interface Ga4BrowserConfig {\n /**\n * How this property spells each canonical event. Rarely needed: the\n * canonical vocabulary already is GA4's.\n */\n events?: Record<string, EventBinding<string>>;\n}\n\n// Server rendering reaches this module with no browser at all, where\n// `window` is a ReferenceError rather than `undefined`.\nfunction gtagWindow(): GtagWindow | undefined {\n return typeof window === 'undefined' ? undefined : (window as unknown as GtagWindow);\n}\n\n/**\n * Sends a command, or fails the way a hand-written call would.\n *\n * Writing `gtag(…)` on a page without Google's snippet throws, and this\n * library replicates that rather than swallowing it: a silent no-op is how a\n * host ends up believing it is measuring something.\n */\nfunction push(...args: unknown[]): void {\n const gtag = gtagWindow()?.gtag;\n\n if (!gtag) {\n throw new Error(\n \"ga4: window.gtag is not defined. Add Google's tag to the page before creating tracking.\",\n );\n }\n\n gtag(...args);\n}\n\n/**\n * Google Analytics 4 in the browser, through `gtag.js`.\n *\n * **This provider does not load Google's tag.** The tag belongs on the page,\n * installed the way Google documents it: a snippet in the HTML, or a tag\n * manager. This library talks to the tag that is already there; it never\n * injects a third-party script into your page, and it never issues the\n * property configuration that would emit a second page view.\n *\n * Which also means the initial consent declaration is not ours to make:\n * Google requires it before the property is configured, and the snippet that\n * configures the property is yours. Use {@link ConsentState} through\n * `consent()` for changes after that.\n *\n * @param measurementId - The `G-` id of the data stream, matching the tag on\n * the page.\n * @param config - Rarely needed event name overrides.\n * @returns A provider for `createTracking` from `tracklane/browser`.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/browser';\n *\n * // Google's own snippet is already in the page.\n * const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });\n *\n * track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });\n * ```\n */\nexport function ga4(measurementId: string, config: Ga4BrowserConfig = {}): BrowserProvider {\n const { events } = config;\n\n return {\n name: 'ga4',\n // The canonical names already are GA4's, so every event passes through\n // under its own name unless the host says otherwise.\n default: 'passthrough',\n ...(events ? { events } : {}),\n\n track(name: string, data: EventData, _options: TrackOptions): void {\n // GA4 documents no browser-to-server deduplication, so `dedupId` maps\n // to nothing here. That is the exact translation of \"this concept does\n // not exist in this vendor\". Inventing a param would imply a merge\n // the platform never performs.\n //\n // `send_to` is written after the host's data on purpose: without it,\n // gtag sends to every configured property on the page, so the\n // addressing is the adapter's to own and not the host's to override.\n push('event', name, { ...data, send_to: measurementId });\n },\n\n identify(user: UserData, traits?: Record<string, unknown>): void {\n // GA4 documents no browser slot for email, phone or names. They map to\n // nothing, which is what \"this concept does not exist here\" means.\n if (user.userId !== undefined) push('set', { user_id: user.userId });\n if (traits) push('set', 'user_properties', traits);\n },\n\n consent(command: 'default' | 'update', state: ConsentState): void {\n // Verbatim: the library's consent vocabulary is Google's own spelling,\n // so there is nothing to translate and no chance to expand a signal\n // the host did not declare.\n push('consent', command, state);\n },\n };\n}\n","// Through the public entry point, exactly as a provider written elsewhere\n// would import it. A provider that needs something not exported here is\n// telling us the contract is incomplete, which is a conversation rather than\n// a reason to reach inside.\nimport type {\n BrowserProvider,\n ConsentState,\n EventBinding,\n EventData,\n EventItem,\n TrackOptions,\n} from '../index.js';\n\ninterface FbqWindow {\n fbq?: (...args: unknown[]) => void;\n}\n\n/**\n * The names Meta accepts through `fbq('track')`. Anything else is a custom\n * event and goes through `trackCustom`, which is a different command rather\n * than a different argument.\n *\n * Seventeen standard events plus `PageView`, which Meta documents separately\n * from the seventeen and which the base snippet fires by itself.\n */\nconst STANDARD_EVENTS: readonly string[] = [\n 'AddPaymentInfo',\n 'AddToCart',\n 'AddToWishlist',\n 'CompleteRegistration',\n 'Contact',\n 'CustomizeProduct',\n 'Donate',\n 'FindLocation',\n 'InitiateCheckout',\n 'Lead',\n 'PageView',\n 'Purchase',\n 'Schedule',\n 'Search',\n 'StartTrial',\n 'SubmitApplication',\n 'Subscribe',\n 'ViewContent',\n];\n\n/**\n * How Meta spells the canonical vocabulary, which is GA4's.\n *\n * Only canonical names appear here. Meta has eight more standard events with\n * no GA4 counterpart (`Subscribe`, `StartTrial`, `Schedule`, `Contact`,\n * `Donate`, `FindLocation`, `CustomizeProduct`, `SubmitApplication`), and\n * inventing canonical names for them would make this library the author of a\n * vocabulary it deliberately borrows. A host that fires those maps its own\n * event name to them through `events`.\n *\n * A canonical event with no standard counterpart is **not** dropped: it goes\n * to `trackCustom` under its own name, because by hand that event would have\n * been sent.\n */\nconst CANONICAL_EVENTS: Record<string, EventBinding<string>> = {\n page_view: 'PageView',\n view_item: 'ViewContent',\n search: 'Search',\n add_to_cart: 'AddToCart',\n add_to_wishlist: 'AddToWishlist',\n begin_checkout: 'InitiateCheckout',\n add_payment_info: 'AddPaymentInfo',\n purchase: 'Purchase',\n sign_up: 'CompleteRegistration',\n generate_lead: 'Lead',\n};\n\n/** Optional configuration for the Meta browser provider. */\nexport interface MetaBrowserConfig {\n /**\n * How this pixel spells each canonical event, merged over the built-in map.\n *\n * This is how the eight standard events with no GA4 counterpart are\n * reached, and how an event is kept out of Meta entirely with `null`.\n */\n events?: Record<string, EventBinding<string>>;\n}\n\n// Server rendering reaches this module with no browser at all, where\n// `window` is a ReferenceError rather than `undefined`.\nfunction fbqWindow(): FbqWindow | undefined {\n return typeof window === 'undefined' ? undefined : (window as unknown as FbqWindow);\n}\n\n/**\n * Sends a command, or fails the way a hand-written call would.\n *\n * Writing `fbq(…)` on a page without Meta's snippet throws, and this library\n * replicates that rather than swallowing it: a silent no-op is how a host\n * ends up believing it is measuring something.\n */\nfunction push(...args: unknown[]): void {\n const fbq = fbqWindow()?.fbq;\n\n if (!fbq) {\n throw new Error(\n \"meta: window.fbq is not defined. Add Meta's pixel to the page before creating tracking.\",\n );\n }\n\n fbq(...args);\n}\n\n/**\n * GA4's `items` become Meta's `contents`, which is a closed shape: `id`,\n * `quantity`, `item_price` and `delivery_category`, and nothing else.\n *\n * The rest of a GA4 item, `item_name` and `item_brand`, is dropped rather than\n * forwarded, because a key Meta does not read inside `contents` looks mapped\n * and arrives nowhere. That is the failure mode this project keeps finding.\n */\nfunction contents(items: readonly EventItem[]): Record<string, unknown>[] {\n return items.map((item) => {\n const content: Record<string, unknown> = {};\n\n if (item.item_id !== undefined) content.id = item.item_id;\n if (item.quantity !== undefined) content.quantity = item.quantity;\n if (item.price !== undefined) content.item_price = item.price;\n\n return content;\n });\n}\n\n/**\n * `value` and `currency` are spelled identically by both vendors, so they\n * pass through untouched. Everything the host named itself passes through\n * too: Meta keeps unknown keys as custom properties, which is exactly what a\n * hand-written call would have produced.\n *\n * `transaction_id` stays under its own name here. Meta's pixel documents no\n * order id among its object properties. `order_id` exists on the Conversions\n * API and not on this surface, so translating it would be inventing a slot.\n */\nfunction pixelParams(data: EventData): Record<string, unknown> {\n const { items, ...rest } = data;\n const params: Record<string, unknown> = { ...rest };\n\n if (items) params.contents = contents(items);\n\n return params;\n}\n\n/**\n * Meta's pixel has one consent switch where this library carries Google's\n * four signals, so the four are collapsed into it.\n *\n * The collapse is the strictest declared answer: any `denied` produces\n * `revoke`, and `grant` needs every declared signal to be `granted`. There is\n * no field to partition, so this is the only reduction that cannot widen a\n * denial, and widening is the one thing a projection must never do.\n *\n * A host that wants Meta to follow only its advertising category, and not its\n * analytics one, decides that before `createTracking` by leaving this\n * provider out, which is what `@tracklane/consent` exists to do.\n */\nfunction collapse(state: ConsentState): 'grant' | 'revoke' | undefined {\n const signals = [\n state.ad_storage,\n state.analytics_storage,\n state.ad_user_data,\n state.ad_personalization,\n ].filter((signal) => signal !== undefined);\n\n // Nothing declared produces no command. Reading silence as either answer\n // puts words in the visitor's mouth.\n if (signals.length === 0) return undefined;\n\n return signals.every((signal) => signal === 'granted') ? 'grant' : 'revoke';\n}\n\n/**\n * The Meta pixel in the browser, through `fbq`.\n *\n * **This provider does not load Meta's pixel.** The base code belongs on the\n * page, installed the way\n * [Meta documents it](https://developers.facebook.com/docs/meta-pixel/get-started):\n * a snippet in the HTML, or a tag manager. This library talks to the pixel\n * that is already there; it never injects a third-party script and never\n * issues `fbq('init')`.\n *\n * Three consequences follow from that, and all three are Meta's rules rather\n * than ours:\n *\n * - **There is no `identify` here.** Meta accepts advanced matching only in\n * the base code (\"be sure to place advanced matching parameters in the\n * pixel base code or the values will not be treated as manual advanced\n * matching values\"), and the base code is yours. Put `em`, `ph`, `fn`, `ln`\n * in your own `fbq('init')`, where the pixel normalises and hashes them for\n * you. On the server, where there is no snippet, the same identity travels\n * in `context.user` and is hashed by the adapter.\n * - **The initial consent declaration is not ours.** Meta wants\n * `fbq('consent', 'revoke')` before `init`, and by the time you hold what\n * `createTracking` returns that moment has passed. Ours is every\n * declaration after it, through `consent()`.\n * - **Limited Data Use is not ours either.** `fbq('dataProcessingOptions', …)`\n * is documented as running before `init`. It belongs in your snippet. The\n * server half, which has no snippet, carries it on its factory.\n *\n * **There is no pixel id here**, and that is deliberate rather than an\n * omission. `fbq('track')` reaches every pixel the page initialised, so an id\n * passed in would address nothing and the parameter would promise something\n * this adapter cannot deliver. Which pixels exist is declared by your snippet,\n * in `fbq('init')`.\n *\n * @param config - Event name overrides, merged over the built-in map.\n * @returns A provider for `createTracking` from `tracklane/browser`.\n *\n * @example\n * ```ts\n * import { createTracking, meta } from 'tracklane/browser';\n *\n * // Meta's own base code is already in the page, and it named the pixel.\n * const { track } = createTracking({ providers: [meta()] });\n *\n * // fbq('track', 'Purchase', { value: 49.9, currency: 'BRL' }, { eventID: 'T-1' })\n * track('purchase', { value: 49.9, currency: 'BRL' }, { dedupId: 'T-1' });\n * ```\n */\nexport function meta(config: MetaBrowserConfig = {}): BrowserProvider {\n return {\n name: 'meta',\n // An event Meta has no standard name for is still an event Meta accepts,\n // through `trackCustom`. Nothing is silently dropped.\n default: 'passthrough',\n events: { ...CANONICAL_EVENTS, ...config.events },\n\n track(name: string, data: EventData, options: TrackOptions): void {\n // `track`, which is what a hand-written call is, and the only form Meta\n // documents the deduplication object for.\n //\n // `trackSingle` exists and would address one pixel, the way GA4's\n // `send_to` does. It was used here and then abandoned: Meta publishes no\n // signature for `trackSingle` carrying `{ eventID }`, so the placement\n // that deduplication depends on had no contract behind it. It worked when\n // verified against a real pixel, and an undocumented shape that works\n // today can stop working with no error and no visible symptom — the\n // browser and server hits would simply both count.\n //\n // The cost is Meta's own: on a page with two pixels initialised, `track`\n // reaches both. That is what `fbq('track')` does by hand, so this\n // replicates it rather than deciding otherwise on the host's behalf.\n const command = STANDARD_EVENTS.includes(name) ? 'track' : 'trackCustom';\n const args: unknown[] = [command, name, pixelParams(data)];\n\n // Meta's key is `eventID` here and `event_id` on the Conversions API,\n // matched together with the event name. The library never invents the\n // value: only one the host already owns can match on both sides.\n if (options.dedupId !== undefined) args.push({ eventID: options.dedupId });\n\n push(...args);\n },\n\n consent(_command: 'default' | 'update', state: ConsentState): void {\n // Meta has one consent command and no equivalent of Google's\n // default/update split, so the command maps to nothing. What is\n // declared is the state, and that is what is forwarded.\n const answer = collapse(state);\n if (answer) push('consent', answer);\n },\n };\n}\n","import { assertUniqueProviders, makeReporter, messageOf, resolveEvent } from './dispatch.js';\n\nexport { type Ga4BrowserConfig, ga4 } from './providers/ga4.browser.js';\nexport { type MetaBrowserConfig, meta } from './providers/meta.browser.js';\n\nimport type {\n ConsentState,\n EventBinding,\n EventData,\n ProviderDefault,\n TrackingError,\n UserData,\n} from './vocabulary.js';\n\n/**\n * Everything the browser needs beyond the event itself.\n *\n * There is one field, and that is the point: in the browser each vendor tag\n * reads its own cookies and holds its own session, so the only thing left\n * for the host to supply is the value that ties this hit to a server-side\n * one.\n */\nexport interface TrackOptions {\n /**\n * The value that lets a vendor recognise this hit and a server-side hit as\n * one event. The library never invents it: the browser and the server do\n * not share a process, so only a value the host already owns (an order id)\n * can match on both sides.\n */\n dedupId?: string;\n}\n\n/**\n * A destination for browser events.\n *\n * This is the whole contract. A provider written outside this package\n * implements it exactly as the providers shipped here do.\n */\nexport interface BrowserProvider<Target = string> {\n /** The vendor's fixed name. Registering one twice is an error. */\n name: string;\n /** What happens to a canonical event this vendor has no binding for. */\n default: ProviderDefault;\n /** How this vendor spells each canonical event. */\n events?: Record<string, EventBinding<Target>>;\n /**\n * Loads the vendor tag, at creation.\n *\n * **Make it idempotent.** A host that rebuilds its tracking, as\n * `@tracklane/consent` does on every consent answer, creates again and\n * installs again, including providers that were already there.\n */\n install?(): void | Promise<void>;\n /** Sends one event to this vendor. Throwing here reaches `onError`, not the caller. */\n track(name: Target, data: EventData, options: TrackOptions): void;\n /** Absent where the vendor keeps no user of its own. */\n identify?(user: UserData, traits?: Record<string, unknown>): void;\n /** Absent where the vendor documents no consent command. */\n consent?(command: 'default' | 'update', state: ConsentState): void;\n}\n\n/** Options accepted by {@link createTracking}. */\nexport interface BrowserTrackingOptions {\n /** The vendors to send every event to. Registering the same `name` twice throws. */\n providers: readonly BrowserProvider[];\n /**\n * The one diagnostic channel. Without it the library is silent, exactly as\n * a hand-written vendor call is.\n */\n onError?: (error: TrackingError) => void;\n}\n\n/** What {@link createTracking} returns. */\nexport interface BrowserTracking {\n /** Sends one event to every configured vendor. Never throws. */\n track(name: string, data?: EventData, options?: TrackOptions): void;\n /** Forwards the user to the vendors that hold one, and to nobody else. */\n identify(user: UserData, traits?: Record<string, unknown>): void;\n /**\n * Forwards a consent declaration to every vendor that documents a command\n * for it. It never decides, stores or withholds anything.\n */\n consent(command: 'default' | 'update', state: ConsentState): void;\n}\n\n/**\n * Creates the browser tracking surface.\n *\n * Providers install eagerly, which is what pasting a vendor snippet does and\n * what lets each vendor record its own initial page view. Every vendor\n * shipped here queues its own commands until its script lands, so nothing is\n * lost to loading and this library needs no queue of its own.\n *\n * @param options - The providers to send to, and an optional error channel.\n * @returns The `track`, `identify` and `consent` calls.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/browser';\n *\n * const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });\n * track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });\n * ```\n */\nexport function createTracking(options: BrowserTrackingOptions): BrowserTracking {\n const { providers } = options;\n assertUniqueProviders(providers);\n\n const report = makeReporter(options.onError);\n\n const fail = (provider: string, event: string, cause: unknown): void => {\n report({ provider, event, severity: 'error', message: messageOf(cause), cause });\n };\n\n for (const provider of providers) {\n try {\n const installing = provider.install?.();\n // An async loader that rejects would otherwise become an unhandled\n // rejection, the silent failure this channel exists to prevent.\n void Promise.resolve(installing).catch((cause: unknown) => {\n fail(provider.name, 'install', cause);\n });\n } catch (cause) {\n fail(provider.name, 'install', cause);\n }\n }\n\n return {\n track(name, data = {}, trackOptions = {}) {\n for (const provider of providers) {\n const target = resolveEvent(provider, name);\n if (target === undefined) continue;\n\n try {\n provider.track(target, data, trackOptions);\n } catch (cause) {\n fail(provider.name, name, cause);\n }\n }\n },\n\n identify(user, traits) {\n for (const provider of providers) {\n try {\n provider.identify?.(user, traits);\n } catch (cause) {\n fail(provider.name, 'identify', cause);\n }\n }\n },\n\n consent(command, state) {\n for (const provider of providers) {\n try {\n provider.consent?.(command, state);\n } catch (cause) {\n fail(provider.name, 'consent', cause);\n }\n }\n },\n };\n}\n"],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { i as TrackOptions, n as BrowserTracking, r as BrowserTrackingOptions, t as BrowserProvider } from "./browser-usMUxJhU.js";
|
|
3
|
-
import { a as ServerTrackingOptions, i as ServerTracking, n as ResolvedContext, r as ServerProvider, t as EventContext } from "./server-DRda44Tb.js";
|
|
1
|
+
import { C as UserData, S as TrackingError, _ as ConsentState, b as EventItem, d as ResolvedContext, f as ServerProvider, g as ActionSource, i as TrackOptions, m as ServerTrackingOptions, n as BrowserTracking, p as ServerTracking, r as BrowserTrackingOptions, t as BrowserProvider, u as EventContext, v as EventBinding, x as ProviderDefault, y as EventData } from "./browser-DmJeRMFB.js";
|
|
4
2
|
export type { ActionSource, BrowserProvider, BrowserTracking, BrowserTrackingOptions, ConsentState, EventBinding, EventContext, EventData, EventItem, ProviderDefault, ResolvedContext, ServerProvider, ServerTracking, ServerTrackingOptions, TrackOptions, TrackingError, UserData };
|
package/dist/server.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export { EventContext, type Ga4ServerConfig, type Ga4ServerCredentials, ResolvedContext, ServerProvider, ServerTracking, ServerTrackingOptions, createTracking, ga4 };
|
|
1
|
+
import { D as Ga4ServerConfig, E as meta, O as Ga4ServerCredentials, T as MetaServerCredentials, d as ResolvedContext, f as ServerProvider, h as createTracking, k as ga4, m as ServerTrackingOptions, p as ServerTracking, u as EventContext, w as MetaServerConfig } from "./browser-DmJeRMFB.js";
|
|
2
|
+
export { EventContext, type Ga4ServerConfig, type Ga4ServerCredentials, type MetaServerConfig, type MetaServerCredentials, ResolvedContext, ServerProvider, ServerTracking, ServerTrackingOptions, createTracking, ga4, meta };
|
package/dist/server.js
CHANGED
|
@@ -108,6 +108,217 @@ function ga4(credentials, config = {}) {
|
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
110
|
//#endregion
|
|
111
|
+
//#region src/providers/meta.server.ts
|
|
112
|
+
const GRAPH = "https://graph.facebook.com";
|
|
113
|
+
/**
|
|
114
|
+
* Pinned on purpose. A Graph URL with no version resolves to the *oldest*
|
|
115
|
+
* version still available, which silently changes what the endpoint accepts
|
|
116
|
+
* the day Meta drops it.
|
|
117
|
+
*/
|
|
118
|
+
const DEFAULT_API_VERSION = "v25.0";
|
|
119
|
+
/**
|
|
120
|
+
* How Meta spells the canonical vocabulary, which is GA4's. Kept in step with
|
|
121
|
+
* `meta.browser.ts` by hand: the two halves are two libraries, and neither
|
|
122
|
+
* imports the other.
|
|
123
|
+
*/
|
|
124
|
+
const CANONICAL_EVENTS = {
|
|
125
|
+
page_view: "PageView",
|
|
126
|
+
view_item: "ViewContent",
|
|
127
|
+
search: "Search",
|
|
128
|
+
add_to_cart: "AddToCart",
|
|
129
|
+
add_to_wishlist: "AddToWishlist",
|
|
130
|
+
begin_checkout: "InitiateCheckout",
|
|
131
|
+
add_payment_info: "AddPaymentInfo",
|
|
132
|
+
purchase: "Purchase",
|
|
133
|
+
sign_up: "CompleteRegistration",
|
|
134
|
+
generate_lead: "Lead"
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Web Crypto rather than `node:crypto`, so the built server bundle stays
|
|
138
|
+
* platform-neutral: this adapter is called from Cloudflare Workers, Deno and
|
|
139
|
+
* Vercel's edge runtime as often as from a Node process, and a `node:` import
|
|
140
|
+
* would rule those out for no gain.
|
|
141
|
+
*/
|
|
142
|
+
async function sha256(value) {
|
|
143
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
144
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* **Why the adapter hashes and does not ask the host to.**
|
|
148
|
+
*
|
|
149
|
+
* This looks like the library transforming data, and the cutting rule forbids
|
|
150
|
+
* that. It is not. Meta does not define a field called "email" that happens
|
|
151
|
+
* to want a hash: it defines `em` *as* the SHA-256 of the trimmed, lowercased
|
|
152
|
+
* address, the same way it defines `event_time` as seconds rather than
|
|
153
|
+
* milliseconds. Writing this line by hand you write
|
|
154
|
+
* `sha256(email.trim().toLowerCase())`, and the adapter replicates exactly
|
|
155
|
+
* that. Normalisation lives with the field it belongs to.
|
|
156
|
+
*
|
|
157
|
+
* The alternative is worse and is where integrations demonstrably go wrong:
|
|
158
|
+
* three vendors want SHA-256 under three different normalisation rules, so a
|
|
159
|
+
* host hashing on its own side has to hash the same address three ways and
|
|
160
|
+
* discovers a mistake only as a quietly poor match rate.
|
|
161
|
+
*/
|
|
162
|
+
const normaliseEmail = (value) => value.trim().toLowerCase();
|
|
163
|
+
/**
|
|
164
|
+
* "Remove symbols, letters, and any leading zeros." The country code is not
|
|
165
|
+
* added here: Meta wants one and the library does not know it, and guessing a
|
|
166
|
+
* country onto a phone number invents an identifier for a different person.
|
|
167
|
+
*/
|
|
168
|
+
const normalisePhone = (value) => value.replace(/\D/g, "").replace(/^0+/, "");
|
|
169
|
+
/** "Lowercase only with no punctuation." Spaces and accents survive; Meta reads UTF-8. */
|
|
170
|
+
const normaliseName = (value) => value.trim().toLowerCase().replace(/[\p{P}\p{S}]/gu, "");
|
|
171
|
+
/**
|
|
172
|
+
* GA4's `items` become Meta's `contents`, a closed shape: `id`, `quantity`,
|
|
173
|
+
* `item_price`, `delivery_category`. The rest of a GA4 item is dropped rather
|
|
174
|
+
* than forwarded, because a key Meta does not read inside `contents` looks
|
|
175
|
+
* mapped and arrives nowhere.
|
|
176
|
+
*/
|
|
177
|
+
function contents(items) {
|
|
178
|
+
return items.map((item) => {
|
|
179
|
+
const content = {};
|
|
180
|
+
if (item.item_id !== void 0) content.id = item.item_id;
|
|
181
|
+
if (item.quantity !== void 0) content.quantity = item.quantity;
|
|
182
|
+
if (item.price !== void 0) content.item_price = item.price;
|
|
183
|
+
return content;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* `value` and `currency` are spelled identically by both vendors. Everything
|
|
188
|
+
* the host named itself passes through, because a business measures things
|
|
189
|
+
* this vocabulary does not name.
|
|
190
|
+
*
|
|
191
|
+
* `transaction_id` becomes `order_id`, which this surface documents and the
|
|
192
|
+
* pixel does not. It is one of the asymmetries between Meta's two halves an
|
|
193
|
+
* adapter absorbs so nobody else has to.
|
|
194
|
+
*/
|
|
195
|
+
function customData(data) {
|
|
196
|
+
const { items, transaction_id: transactionId, ...rest } = data;
|
|
197
|
+
const custom = { ...rest };
|
|
198
|
+
if (transactionId !== void 0) custom.order_id = transactionId;
|
|
199
|
+
if (items) custom.contents = contents(items);
|
|
200
|
+
return custom;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The heaviest surface of the five vendors: four hashed identifiers, four raw
|
|
204
|
+
* ones, and two cookies forwarded exactly as the pixel wrote them.
|
|
205
|
+
*
|
|
206
|
+
* Built in a fixed order, and hashed one after another rather than in
|
|
207
|
+
* parallel, so that the same input produces the same JSON on every run. Two
|
|
208
|
+
* identical events differing only in key order would be the library inventing
|
|
209
|
+
* something, which is precisely what the conformance suite looks for.
|
|
210
|
+
*/
|
|
211
|
+
async function userData(context) {
|
|
212
|
+
const user = context.user ?? {};
|
|
213
|
+
const built = {};
|
|
214
|
+
const hashed = [
|
|
215
|
+
[
|
|
216
|
+
"em",
|
|
217
|
+
user.email,
|
|
218
|
+
normaliseEmail
|
|
219
|
+
],
|
|
220
|
+
[
|
|
221
|
+
"ph",
|
|
222
|
+
user.phone,
|
|
223
|
+
normalisePhone
|
|
224
|
+
],
|
|
225
|
+
[
|
|
226
|
+
"fn",
|
|
227
|
+
user.firstName,
|
|
228
|
+
normaliseName
|
|
229
|
+
],
|
|
230
|
+
[
|
|
231
|
+
"ln",
|
|
232
|
+
user.lastName,
|
|
233
|
+
normaliseName
|
|
234
|
+
]
|
|
235
|
+
];
|
|
236
|
+
for (const [field, raw, normalise] of hashed) {
|
|
237
|
+
if (raw === void 0) continue;
|
|
238
|
+
const normalised = normalise(raw);
|
|
239
|
+
if (!normalised) continue;
|
|
240
|
+
built[field] = await sha256(normalised);
|
|
241
|
+
}
|
|
242
|
+
if (user.userId !== void 0) built.external_id = user.userId;
|
|
243
|
+
if (context.ip !== void 0) built.client_ip_address = context.ip;
|
|
244
|
+
if (context.userAgent !== void 0) built.client_user_agent = context.userAgent;
|
|
245
|
+
const fbp = context.cookies._fbp;
|
|
246
|
+
if (fbp) built.fbp = fbp;
|
|
247
|
+
const fbc = context.cookies._fbc;
|
|
248
|
+
if (fbc) built.fbc = fbc;
|
|
249
|
+
return built;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Meta's Conversions API.
|
|
253
|
+
*
|
|
254
|
+
* @param credentials - The pixel id and an access token.
|
|
255
|
+
* @param config - Event overrides, the default action source, and Meta's own
|
|
256
|
+
* levers: the API version, a test event code, and Limited Data Use.
|
|
257
|
+
* @returns A provider for `createTracking` from `tracklane/server`.
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* ```ts
|
|
261
|
+
* import { createTracking, meta } from 'tracklane/server';
|
|
262
|
+
*
|
|
263
|
+
* const { track } = createTracking({
|
|
264
|
+
* providers: [meta({ pixelId: '1234567890', accessToken: process.env.META_TOKEN! })],
|
|
265
|
+
* });
|
|
266
|
+
*
|
|
267
|
+
* await track('purchase', order, {
|
|
268
|
+
* user: { email: order.email },
|
|
269
|
+
* cookies: request.headers.get('cookie'),
|
|
270
|
+
* dedupId: order.id,
|
|
271
|
+
* ip,
|
|
272
|
+
* userAgent,
|
|
273
|
+
* });
|
|
274
|
+
* ```
|
|
275
|
+
*/
|
|
276
|
+
function meta(credentials, config = {}) {
|
|
277
|
+
const { pixelId, accessToken } = credentials;
|
|
278
|
+
const { actionSource = "website", apiVersion = DEFAULT_API_VERSION, testEventCode } = config;
|
|
279
|
+
return {
|
|
280
|
+
name: "meta",
|
|
281
|
+
default: "passthrough",
|
|
282
|
+
events: {
|
|
283
|
+
...CANONICAL_EVENTS,
|
|
284
|
+
...config.events
|
|
285
|
+
},
|
|
286
|
+
async track(name, data, context, report) {
|
|
287
|
+
const user = await userData(context);
|
|
288
|
+
if (Object.keys(user).length === 0) throw new Error("meta: cannot build user_data, the Conversions API requires at least one identifier. Pass context.user, context.ip with context.userAgent, or the _fbp/_fbc cookies.");
|
|
289
|
+
const event = {
|
|
290
|
+
event_name: name,
|
|
291
|
+
event_time: Math.floor(context.timestamp / 1e3),
|
|
292
|
+
action_source: context.source ?? actionSource,
|
|
293
|
+
user_data: user
|
|
294
|
+
};
|
|
295
|
+
if (context.dedupId !== void 0) event.event_id = context.dedupId;
|
|
296
|
+
if (context.url !== void 0) event.event_source_url = context.url;
|
|
297
|
+
const custom = customData(data);
|
|
298
|
+
if (Object.keys(custom).length > 0) event.custom_data = custom;
|
|
299
|
+
if (config.dataProcessingOptions) {
|
|
300
|
+
event.data_processing_options = config.dataProcessingOptions;
|
|
301
|
+
if (config.dataProcessingOptionsCountry !== void 0) event.data_processing_options_country = config.dataProcessingOptionsCountry;
|
|
302
|
+
if (config.dataProcessingOptionsState !== void 0) event.data_processing_options_state = config.dataProcessingOptionsState;
|
|
303
|
+
}
|
|
304
|
+
const body = new URLSearchParams({
|
|
305
|
+
data: JSON.stringify([event]),
|
|
306
|
+
access_token: accessToken
|
|
307
|
+
});
|
|
308
|
+
if (testEventCode) body.set("test_event_code", testEventCode);
|
|
309
|
+
const response = await fetch(`${GRAPH}/${apiVersion}/${encodeURIComponent(pixelId)}/events`, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
312
|
+
body
|
|
313
|
+
});
|
|
314
|
+
if (!response.ok) throw new Error(`meta: the Conversions API answered ${response.status}`);
|
|
315
|
+
const result = await response.json().catch(() => null);
|
|
316
|
+
const messages = result?.messages;
|
|
317
|
+
if (Array.isArray(messages) && messages.length > 0) report(`accepted with ${messages.length} warning(s) from the Conversions API; read them under fbtrace_id ${result?.fbtrace_id ?? "unknown"} in Events Manager`);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
111
322
|
//#region src/server.ts
|
|
112
323
|
/** `name=value; name=value`, the shape of a `Cookie` header. */
|
|
113
324
|
function parseCookies(cookies) {
|
|
@@ -185,6 +396,6 @@ function createTracking(options) {
|
|
|
185
396
|
} };
|
|
186
397
|
}
|
|
187
398
|
//#endregion
|
|
188
|
-
export { createTracking, ga4 };
|
|
399
|
+
export { createTracking, ga4, meta };
|
|
189
400
|
|
|
190
401
|
//# sourceMappingURL=server.js.map
|