tracklane 0.1.0 → 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/LICENSE +21 -0
- package/README.md +88 -5
- package/dist/browser-DmJeRMFB.d.ts +552 -0
- package/dist/browser.d.ts +2 -2
- package/dist/browser.js +207 -9
- package/dist/browser.js.map +1 -1
- package/dist/{dispatch-DK2GBnkt.js → dispatch-BLBoCn-b.js} +2 -2
- package/dist/dispatch-BLBoCn-b.js.map +1 -0
- package/dist/index.d.ts +1 -3
- package/dist/server.d.ts +2 -2
- package/dist/server.js +221 -10
- package/dist/server.js.map +1 -1
- package/package.json +13 -13
- package/dist/browser-BQNJ4qUq.d.ts +0 -124
- package/dist/dispatch-DK2GBnkt.js.map +0 -1
- package/dist/server-cNxLsKhl.d.ts +0 -152
- package/dist/vocabulary-ByyXHVWd.d.ts +0 -107
package/dist/server.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { i as resolveEvent, n as makeReporter, r as messageOf, t as assertUniqueProviders } from "./dispatch-
|
|
1
|
+
import { i as resolveEvent, n as makeReporter, r as messageOf, t as assertUniqueProviders } from "./dispatch-BLBoCn-b.js";
|
|
2
2
|
//#region src/providers/ga4.server.ts
|
|
3
3
|
const ENDPOINT = "https://www.google-analytics.com/mp/collect";
|
|
4
4
|
/**
|
|
5
5
|
* The visitor cookie holds `GA1.<depth>.<client_id>`, and the client id
|
|
6
|
-
* itself contains a dot
|
|
6
|
+
* itself contains a dot, so the identifier is the last two segments, which
|
|
7
7
|
* is what survives Google's varying prefix depth.
|
|
8
8
|
*
|
|
9
9
|
* The value must be the cookie as the browser wrote it. A host passing a
|
|
@@ -21,14 +21,14 @@ function visitorId(cookies) {
|
|
|
21
21
|
*
|
|
22
22
|
* Verified against Google's live tag on 2026-07-31 rather than assumed:
|
|
23
23
|
*
|
|
24
|
-
* - `GS2.1.s1785511401$o1$g0$t1785511401$j60$l0$h0
|
|
24
|
+
* - `GS2.1.s1785511401$o1$g0$t1785511401$j60$l0$h0`, the current one. The
|
|
25
25
|
* third dot-segment packs several fields joined by `$`, and the session is
|
|
26
26
|
* the first of them, prefixed with `s`.
|
|
27
|
-
* - `GS1.1.1785511401.1.1
|
|
27
|
+
* - `GS1.1.1785511401.1.1.…`, the older one, where the third dot-segment is
|
|
28
28
|
* the session on its own.
|
|
29
29
|
*
|
|
30
30
|
* Reading the third segment blindly works for the old layout and returns the
|
|
31
|
-
* whole `s…$o1$g0$…` blob for the new one
|
|
31
|
+
* whole `s…$o1$g0$…` blob for the new one, which the vendor would accept and
|
|
32
32
|
* silently fail to match. Hence the two shapes, and hence `undefined` rather
|
|
33
33
|
* than a guess when neither fits.
|
|
34
34
|
*/
|
|
@@ -41,7 +41,7 @@ function sessionId(cookies, measurementId) {
|
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
43
|
* The Measurement Protocol documents exactly two consent fields, and spells
|
|
44
|
-
* their values in upper case where the browser spells them in lower
|
|
44
|
+
* their values in upper case where the browser spells them in lower, one of
|
|
45
45
|
* the small asymmetries an adapter absorbs so nobody else has to.
|
|
46
46
|
*/
|
|
47
47
|
function consentBody(state) {
|
|
@@ -77,7 +77,7 @@ function ga4(credentials, config = {}) {
|
|
|
77
77
|
...config.events ? { events: config.events } : {},
|
|
78
78
|
async track(name, data, context, report) {
|
|
79
79
|
const clientId = visitorId(context.cookies);
|
|
80
|
-
if (!clientId) throw new Error("ga4: cannot build client_id
|
|
80
|
+
if (!clientId) throw new Error("ga4: cannot build client_id, no _ga cookie among the cookies passed in context. Ensure GA4's browser tag has set it first.");
|
|
81
81
|
const session = sessionId(context.cookies, measurementId);
|
|
82
82
|
if (!session) report("no session cookie (_ga_<container>): Google will accept this event and it will not appear in any report");
|
|
83
83
|
const params = { ...data };
|
|
@@ -108,8 +108,219 @@ 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
|
-
/** `name=value; name=value
|
|
323
|
+
/** `name=value; name=value`, the shape of a `Cookie` header. */
|
|
113
324
|
function parseCookies(cookies) {
|
|
114
325
|
if (!cookies) return {};
|
|
115
326
|
if (typeof cookies !== "string") return cookies;
|
|
@@ -130,7 +341,7 @@ function parseCookies(cookies) {
|
|
|
130
341
|
* one visitor to another visitor's conversion. Identity travels with the
|
|
131
342
|
* call, which is also what every conversion API expects.
|
|
132
343
|
*
|
|
133
|
-
* There is no `consent` call either
|
|
344
|
+
* There is no `consent` call either. Consent commands are properties of a
|
|
134
345
|
* vendor's on-page tag, and a server request has no tag. The one vendor that
|
|
135
346
|
* accepts consent server-side reads it from the context.
|
|
136
347
|
*
|
|
@@ -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
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","names":[],"sources":["../src/providers/ga4.server.ts","../src/server.ts"],"sourcesContent":["import type { ResolvedContext, ServerProvider } from '../server.js';\nimport type { ConsentState, EventBinding, EventData } from '../vocabulary.js';\n\nconst ENDPOINT = 'https://www.google-analytics.com/mp/collect';\n\n/** Credentials for the GA4 Measurement Protocol. */\nexport interface Ga4ServerCredentials {\n /** The `G-` id of the data stream. */\n measurementId: string;\n /** Created under Admin → Data Streams → Measurement Protocol. */\n apiSecret: string;\n}\n\n/** Optional configuration for the GA4 server provider. */\nexport interface Ga4ServerConfig {\n events?: Record<string, EventBinding<string>>;\n}\n\n/**\n * The visitor cookie holds `GA1.<depth>.<client_id>`, and the client id\n * itself contains a dot — so the identifier is the last two segments, which\n * is what survives Google's varying prefix depth.\n *\n * The value must be the cookie as the browser wrote it. A host passing a\n * parsed map passes cookie values, not values it took apart itself.\n */\nfunction visitorId(cookies: Record<string, string>): string | undefined {\n const raw = cookies._ga;\n if (!raw) return undefined;\n\n const parts = raw.split('.');\n if (parts.length < 4) return undefined;\n\n return parts.slice(-2).join('.');\n}\n\n/**\n * The per-container cookie carries the session, in one of two layouts.\n *\n * Verified against Google's live tag on 2026-07-31 rather than assumed:\n *\n * - `GS2.1.s1785511401$o1$g0$t1785511401$j60$l0$h0` — the current one. The\n * third dot-segment packs several fields joined by `$`, and the session is\n * the first of them, prefixed with `s`.\n * - `GS1.1.1785511401.1.1.…` — the older one, where the third dot-segment is\n * the session on its own.\n *\n * Reading the third segment blindly works for the old layout and returns the\n * whole `s…$o1$g0$…` blob for the new one — which the vendor would accept and\n * silently fail to match. Hence the two shapes, and hence `undefined` rather\n * than a guess when neither fits.\n */\nfunction sessionId(cookies: Record<string, string>, measurementId: string): string | undefined {\n // Scoped to *this* property's cookie. A page with two GA4 properties has two\n // `_ga_*` cookies, and taking whichever came first would attach another\n // property's session to our event — which Google accepts and never matches.\n const value = cookies[`_ga_${measurementId.replace(/^G-/, '')}`];\n const segment = value?.split('.')[2];\n if (!segment) return undefined;\n\n const packed = /^s(\\d+)/.exec(segment);\n if (packed) return packed[1];\n\n // The older layout: a bare session id, nothing packed around it.\n return /^\\d+$/.test(segment) ? segment : undefined;\n}\n\n/**\n * The Measurement Protocol documents exactly two consent fields, and spells\n * their values in upper case where the browser spells them in lower — one of\n * the small asymmetries an adapter absorbs so nobody else has to.\n */\nfunction consentBody(state: ConsentState | undefined): Record<string, string> | undefined {\n if (!state) return undefined;\n\n const body: Record<string, string> = {};\n if (state.ad_user_data) body.ad_user_data = state.ad_user_data.toUpperCase();\n if (state.ad_personalization) body.ad_personalization = state.ad_personalization.toUpperCase();\n\n return Object.keys(body).length > 0 ? body : undefined;\n}\n\n/**\n * Google Analytics 4 on the server, through the Measurement Protocol.\n *\n * @param credentials - The measurement id and API secret.\n * @param config - Rarely needed event name overrides.\n * @returns A provider for `createTracking` from `tracklane/server`.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/server';\n *\n * const { track } = createTracking({\n * providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret: process.env.GA4_SECRET! })],\n * });\n *\n * await track('purchase', order, { cookies: request.headers.get('cookie') });\n * ```\n */\nexport function ga4(\n credentials: Ga4ServerCredentials,\n config: Ga4ServerConfig = {},\n): ServerProvider {\n const { measurementId, apiSecret } = credentials;\n\n return {\n name: 'ga4',\n default: 'passthrough',\n ...(config.events ? { events: config.events } : {}),\n\n async track(\n name: string,\n data: EventData,\n context: ResolvedContext,\n report: (message: string) => void,\n ): Promise<void> {\n const clientId = visitorId(context.cookies);\n if (!clientId) {\n // Not withholding on policy grounds: the protocol requires this and\n // it cannot be invented. The endpoint answers success even to\n // invalid payloads, so sending anyway would look like it worked.\n // Naming the vendor's own field, so that searching Google's docs\n // for the error leads somewhere.\n throw new Error(\n 'ga4: cannot build client_id — no _ga cookie among the cookies passed in context',\n );\n }\n\n const session = sessionId(context.cookies, measurementId);\n if (!session) {\n report(\n 'no session cookie (_ga_<container>): Google will accept this event and it will not appear in any report',\n );\n }\n\n const params: Record<string, unknown> = { ...data };\n if (session) {\n params.session_id = session;\n // Google drops sessions with no engagement from its reports, so the\n // event would arrive and still be invisible without this.\n params.engagement_time_msec = 1;\n }\n\n // The dedup id maps nowhere: GA4 documents no browser-to-server\n // deduplication. `transaction_id` — business data — travels in params\n // and is the duplicate control this vendor does have.\n const body: Record<string, unknown> = {\n client_id: clientId,\n timestamp_micros: context.timestamp * 1000,\n events: [{ name, params }],\n };\n\n if (context.user?.userId !== undefined) body.user_id = context.user.userId;\n\n if (context.traits) {\n // Each property is an object with a `value` key here, where the\n // browser takes the bare value. Sending the bare value is rejected\n // by Google's validation endpoint and **accepted with 204 by the\n // collection endpoint**, which then drops it — verified on\n // 2026-07-31. One of several places where this vendor fails by\n // accepting.\n body.user_properties = Object.fromEntries(\n Object.entries(context.traits).map(([name, value]) => [name, { value }]),\n );\n }\n\n const consent = consentBody(context.consent);\n if (consent) body.consent = consent;\n\n // Credentials go in the query because the protocol offers no\n // alternative. Query strings land in proxy and APM logs; that\n // exposure is Google's design, not a choice made here.\n const url = `${ENDPOINT}?measurement_id=${encodeURIComponent(measurementId)}&api_secret=${encodeURIComponent(apiSecret)}`;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n });\n\n if (!response.ok) {\n // The status only. The event carries raw personal data, and no\n // library-owned surface re-emits it into a host's logs.\n throw new Error(`ga4: the Measurement Protocol answered ${response.status}`);\n }\n },\n };\n}\n","import { assertUniqueProviders, makeReporter, messageOf, resolveEvent } from './dispatch.js';\n\nexport {\n type Ga4ServerConfig,\n type Ga4ServerCredentials,\n ga4,\n} from './providers/ga4.server.js';\n\nimport type {\n ActionSource,\n ConsentState,\n EventBinding,\n EventData,\n ProviderDefault,\n TrackingError,\n UserData,\n} from './vocabulary.js';\n\n/**\n * Everything a conversion API needs beyond the event itself: who the person\n * is, when it happened, and where it was observed.\n *\n * None of this is \"what happened\", which is why it does not travel in the\n * event data — putting it there would leak identity into the pass-through\n * event params of the vendors that forward the payload as-is.\n */\nexport interface EventContext {\n user?: UserData;\n /**\n * The request's raw `Cookie` header, or an already-parsed map.\n *\n * These are cookies the vendors' own browser tags set — this library never\n * writes one. On the server each adapter reads the cookies belonging to\n * its own vendor: Meta forwards its value as-is, Google's holds several\n * things at once and has to be opened. That knowledge lives in the\n * adapters; the core only splits `name=value; name=value` into pairs.\n *\n * **It splits, it does not interpret.** Values are handed over exactly as\n * they appear, with no percent-decoding. The cookies these vendors set do\n * not need it; a host reading some other cookie should decode it itself.\n */\n cookies?: string | Record<string, string> | null;\n /** Ties this send to a browser hit. Never invented by the library. */\n dedupId?: string;\n /**\n * When the conversion actually happened. Defaults to now.\n *\n * A webhook arrives after the fact and LinkedIn rejects conversions older\n * than 90 days, so reporting \"now\" for everything would quietly distort\n * attribution.\n */\n timestamp?: number | Date;\n /** Overrides the factory default. Meta requires it on every event. */\n source?: ActionSource;\n url?: string;\n ip?: string;\n userAgent?: string;\n /** What is known *about* the person: GA4 user properties, PostHog `$set`. */\n traits?: Record<string, unknown>;\n /**\n * Travels inside the event, for the vendors that document such a field.\n * Today that is GA4's Measurement Protocol, and it reads two of the\n * signals; the others have no slot and ignore it.\n */\n consent?: ConsentState;\n}\n\n/** The context an adapter receives, with the ambiguity resolved. */\nexport interface ResolvedContext extends Omit<EventContext, 'cookies' | 'timestamp'> {\n cookies: Record<string, string>;\n /** Epoch milliseconds. Adapters convert to their vendor's spelling. */\n timestamp: number;\n}\n\n/**\n * A destination for server events.\n *\n * `track` may throw — on a non-2xx, or where a vendor documents a\n * precondition the event cannot meet. The dispatcher isolates and reports\n * it, so adapters stay honest and loud.\n *\n * `report` is for a caveat about a request that already succeeded. It is\n * never a throw: by then the send happened, and throwing would tell a host's\n * retry logic to resend a delivered conversion.\n */\nexport interface ServerProvider<Target = string> {\n name: string;\n default: ProviderDefault;\n events?: Record<string, EventBinding<Target>>;\n track(\n name: Target,\n data: EventData,\n context: ResolvedContext,\n report: (message: string) => void,\n ): Promise<void>;\n}\n\n/** Options accepted by {@link createTracking}. */\nexport interface ServerTrackingOptions {\n providers: readonly ServerProvider[];\n onError?: (error: TrackingError) => void;\n}\n\n/** What {@link createTracking} returns. */\nexport interface ServerTracking {\n /**\n * Sends one event to every configured conversion API.\n *\n * Resolves once every provider has settled and never rejects: one vendor\n * being down is not the caller's failure to handle. Awaiting is about the\n * request surviving a serverless runtime, not about delivery being\n * guaranteed.\n */\n track(name: string, data?: EventData, context?: EventContext): Promise<void>;\n}\n\n/** `name=value; name=value` — the shape of a `Cookie` header. */\nfunction parseCookies(\n cookies: string | Record<string, string> | null | undefined,\n): Record<string, string> {\n if (!cookies) return {};\n if (typeof cookies !== 'string') return cookies;\n\n const parsed: Record<string, string> = {};\n\n for (const pair of cookies.split(';')) {\n const separator = pair.indexOf('=');\n if (separator < 1) continue;\n\n const name = pair.slice(0, separator).trim();\n // Cookie values may contain `=`, so only the first one separates.\n if (name) parsed[name] = pair.slice(separator + 1).trim();\n }\n\n return parsed;\n}\n\n/**\n * Creates the server tracking surface.\n *\n * There is no `identify` here and nothing is held between calls: a server\n * instance is shared by every request, so a retained identity would attach\n * one visitor to another visitor's conversion. Identity travels with the\n * call, which is also what every conversion API expects.\n *\n * There is no `consent` call either — consent commands are properties of a\n * vendor's on-page tag, and a server request has no tag. The one vendor that\n * accepts consent server-side reads it from the context.\n *\n * @param options - The providers to send to, and an optional error channel.\n * @returns The `track` call.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/server';\n *\n * const { track } = createTracking({\n * providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret })],\n * });\n *\n * await track('purchase', order, { cookies: request.headers.get('cookie') });\n * ```\n */\nexport function createTracking(options: ServerTrackingOptions): ServerTracking {\n const { providers } = options;\n assertUniqueProviders(providers);\n\n const report = makeReporter(options.onError);\n\n return {\n async track(name, data = {}, context = {}) {\n const { cookies, timestamp, ...rest } = context;\n const resolved: ResolvedContext = {\n ...rest,\n cookies: parseCookies(cookies),\n timestamp: timestamp instanceof Date ? timestamp.getTime() : (timestamp ?? Date.now()),\n };\n\n const sends = providers.map(async (provider) => {\n const target = resolveEvent(provider, name);\n if (target === undefined) return;\n\n try {\n await provider.track(target, data, resolved, (message) => {\n report({ provider: provider.name, event: name, severity: 'warning', message });\n });\n } catch (cause) {\n report({\n provider: provider.name,\n event: name,\n severity: 'error',\n message: messageOf(cause),\n cause,\n });\n }\n });\n\n await Promise.all(sends);\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"server.js","names":[],"sources":["../src/providers/ga4.server.ts","../src/providers/meta.server.ts","../src/server.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 ConsentState,\n EventBinding,\n EventData,\n ResolvedContext,\n ServerProvider,\n} from '../index.js';\n\nconst ENDPOINT = 'https://www.google-analytics.com/mp/collect';\n\n/** Credentials for the GA4 Measurement Protocol. */\nexport interface Ga4ServerCredentials {\n /** The `G-` id of the data stream. */\n measurementId: string;\n /** Created under Admin → Data Streams → Measurement Protocol. */\n apiSecret: string;\n}\n\n/** Optional configuration for the GA4 server provider. */\nexport interface Ga4ServerConfig {\n events?: Record<string, EventBinding<string>>;\n}\n\n/**\n * The visitor cookie holds `GA1.<depth>.<client_id>`, and the client id\n * itself contains a dot, so the identifier is the last two segments, which\n * is what survives Google's varying prefix depth.\n *\n * The value must be the cookie as the browser wrote it. A host passing a\n * parsed map passes cookie values, not values it took apart itself.\n */\nfunction visitorId(cookies: Record<string, string>): string | undefined {\n const raw = cookies._ga;\n if (!raw) return undefined;\n\n const parts = raw.split('.');\n if (parts.length < 4) return undefined;\n\n return parts.slice(-2).join('.');\n}\n\n/**\n * The per-container cookie carries the session, in one of two layouts.\n *\n * Verified against Google's live tag on 2026-07-31 rather than assumed:\n *\n * - `GS2.1.s1785511401$o1$g0$t1785511401$j60$l0$h0`, the current one. The\n * third dot-segment packs several fields joined by `$`, and the session is\n * the first of them, prefixed with `s`.\n * - `GS1.1.1785511401.1.1.…`, the older one, where the third dot-segment is\n * the session on its own.\n *\n * Reading the third segment blindly works for the old layout and returns the\n * whole `s…$o1$g0$…` blob for the new one, which the vendor would accept and\n * silently fail to match. Hence the two shapes, and hence `undefined` rather\n * than a guess when neither fits.\n */\nfunction sessionId(cookies: Record<string, string>, measurementId: string): string | undefined {\n // Scoped to *this* property's cookie. A page with two GA4 properties has two\n // `_ga_*` cookies, and taking whichever came first would attach another\n // property's session to our event, which Google accepts and never matches.\n const value = cookies[`_ga_${measurementId.replace(/^G-/, '')}`];\n const segment = value?.split('.')[2];\n if (!segment) return undefined;\n\n const packed = /^s(\\d+)/.exec(segment);\n if (packed) return packed[1];\n\n // The older layout: a bare session id, nothing packed around it.\n return /^\\d+$/.test(segment) ? segment : undefined;\n}\n\n/**\n * The Measurement Protocol documents exactly two consent fields, and spells\n * their values in upper case where the browser spells them in lower, one of\n * the small asymmetries an adapter absorbs so nobody else has to.\n */\nfunction consentBody(state: ConsentState | undefined): Record<string, string> | undefined {\n if (!state) return undefined;\n\n const body: Record<string, string> = {};\n if (state.ad_user_data) body.ad_user_data = state.ad_user_data.toUpperCase();\n if (state.ad_personalization) body.ad_personalization = state.ad_personalization.toUpperCase();\n\n return Object.keys(body).length > 0 ? body : undefined;\n}\n\n/**\n * Google Analytics 4 on the server, through the Measurement Protocol.\n *\n * @param credentials - The measurement id and API secret.\n * @param config - Rarely needed event name overrides.\n * @returns A provider for `createTracking` from `tracklane/server`.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/server';\n *\n * const { track } = createTracking({\n * providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret: process.env.GA4_SECRET! })],\n * });\n *\n * await track('purchase', order, { cookies: request.headers.get('cookie') });\n * ```\n */\nexport function ga4(\n credentials: Ga4ServerCredentials,\n config: Ga4ServerConfig = {},\n): ServerProvider {\n const { measurementId, apiSecret } = credentials;\n\n return {\n name: 'ga4',\n default: 'passthrough',\n ...(config.events ? { events: config.events } : {}),\n\n async track(\n name: string,\n data: EventData,\n context: ResolvedContext,\n report: (message: string) => void,\n ): Promise<void> {\n const clientId = visitorId(context.cookies);\n if (!clientId) {\n // Not withholding on policy grounds: the protocol requires this and\n // it cannot be invented. The endpoint answers success even to\n // invalid payloads, so sending anyway would look like it worked.\n // Naming the vendor's own field, so that searching Google's docs\n // for the error leads somewhere.\n throw new Error(\n \"ga4: cannot build client_id, no _ga cookie among the cookies passed in context. Ensure GA4's browser tag has set it first.\",\n );\n }\n\n const session = sessionId(context.cookies, measurementId);\n if (!session) {\n report(\n 'no session cookie (_ga_<container>): Google will accept this event and it will not appear in any report',\n );\n }\n\n const params: Record<string, unknown> = { ...data };\n if (session) {\n params.session_id = session;\n // Google drops sessions with no engagement from its reports, so the\n // event would arrive and still be invisible without this.\n params.engagement_time_msec = 1;\n }\n\n // The dedup id maps nowhere: GA4 documents no browser-to-server\n // deduplication. `transaction_id` (business data) travels in params\n // and is the duplicate control this vendor does have.\n const body: Record<string, unknown> = {\n client_id: clientId,\n timestamp_micros: context.timestamp * 1000,\n events: [{ name, params }],\n };\n\n if (context.user?.userId !== undefined) body.user_id = context.user.userId;\n\n if (context.traits) {\n // Each property is an object with a `value` key here, where the\n // browser takes the bare value. Sending the bare value is rejected\n // by Google's validation endpoint and **accepted with 204 by the\n // collection endpoint**, which then drops it, verified on\n // 2026-07-31. One of several places where this vendor fails by\n // accepting.\n body.user_properties = Object.fromEntries(\n Object.entries(context.traits).map(([name, value]) => [name, { value }]),\n );\n }\n\n const consent = consentBody(context.consent);\n if (consent) body.consent = consent;\n\n // Credentials go in the query because the protocol offers no\n // alternative. Query strings land in proxy and APM logs; that\n // exposure is Google's design, not a choice made here.\n const url = `${ENDPOINT}?measurement_id=${encodeURIComponent(measurementId)}&api_secret=${encodeURIComponent(apiSecret)}`;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n });\n\n if (!response.ok) {\n // The status only. The event carries raw personal data, and no\n // library-owned surface re-emits it into a host's logs.\n throw new Error(`ga4: the Measurement Protocol answered ${response.status}`);\n }\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 ActionSource,\n EventBinding,\n EventData,\n EventItem,\n ResolvedContext,\n ServerProvider,\n} from '../index.js';\n\nconst GRAPH = 'https://graph.facebook.com';\n\n/**\n * Pinned on purpose. A Graph URL with no version resolves to the *oldest*\n * version still available, which silently changes what the endpoint accepts\n * the day Meta drops it.\n */\nconst DEFAULT_API_VERSION = 'v25.0';\n\n/**\n * How Meta spells the canonical vocabulary, which is GA4's. Kept in step with\n * `meta.browser.ts` by hand: the two halves are two libraries, and neither\n * imports the other.\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/** Credentials for Meta's Conversions API. */\nexport interface MetaServerCredentials {\n /** The pixel (dataset) id events are posted to. */\n pixelId: string;\n /** A system user or page access token with `ads_management`. It is a credential. */\n accessToken: string;\n}\n\n/** Optional configuration for the Meta server provider. */\nexport interface MetaServerConfig {\n /** How this pixel spells each canonical event, merged over the built-in map. */\n events?: Record<string, EventBinding<string>>;\n /**\n * Where conversions happen by default. Meta requires `action_source` on\n * every event, so there is always a value; `context.source` overrides it\n * per call, which is what a webhook and a checkout need.\n */\n actionSource?: ActionSource;\n /** The Graph API version in the URL. Defaults to a pinned one. */\n apiVersion?: string;\n /**\n * Routes events to the Test Events tab in Events Manager instead of the\n * live dataset. **Testing only**: Meta says to remove it before\n * production, and it is the only instrument that shows what a payload\n * actually became.\n */\n testEventCode?: string;\n /**\n * Meta's Limited Data Use marking, forwarded verbatim: `['LDU']` to enable\n * it, `[]` to disable it.\n *\n * This is a processing marking that some US state laws require, not a\n * consent declaration, which is why it is configuration on this factory and\n * not a reading of `context.consent`. The browser half has no equivalent:\n * `fbq('dataProcessingOptions', …)` is documented as running before\n * `fbq('init')`, and the snippet is the host's.\n */\n dataProcessingOptions?: readonly string[];\n /** `0` lets Meta geolocate. Meaningless without `dataProcessingOptions`. */\n dataProcessingOptionsCountry?: number;\n /** `0` lets Meta geolocate. Meaningless without `dataProcessingOptions`. */\n dataProcessingOptionsState?: number;\n}\n\n/**\n * Web Crypto rather than `node:crypto`, so the built server bundle stays\n * platform-neutral: this adapter is called from Cloudflare Workers, Deno and\n * Vercel's edge runtime as often as from a Node process, and a `node:` import\n * would rule those out for no gain.\n */\nasync function sha256(value: string): Promise<string> {\n const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));\n\n return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');\n}\n\n/**\n * **Why the adapter hashes and does not ask the host to.**\n *\n * This looks like the library transforming data, and the cutting rule forbids\n * that. It is not. Meta does not define a field called \"email\" that happens\n * to want a hash: it defines `em` *as* the SHA-256 of the trimmed, lowercased\n * address, the same way it defines `event_time` as seconds rather than\n * milliseconds. Writing this line by hand you write\n * `sha256(email.trim().toLowerCase())`, and the adapter replicates exactly\n * that. Normalisation lives with the field it belongs to.\n *\n * The alternative is worse and is where integrations demonstrably go wrong:\n * three vendors want SHA-256 under three different normalisation rules, so a\n * host hashing on its own side has to hash the same address three ways and\n * discovers a mistake only as a quietly poor match rate.\n */\nconst normaliseEmail = (value: string): string => value.trim().toLowerCase();\n\n/**\n * \"Remove symbols, letters, and any leading zeros.\" The country code is not\n * added here: Meta wants one and the library does not know it, and guessing a\n * country onto a phone number invents an identifier for a different person.\n */\nconst normalisePhone = (value: string): string => value.replace(/\\D/g, '').replace(/^0+/, '');\n\n/** \"Lowercase only with no punctuation.\" Spaces and accents survive; Meta reads UTF-8. */\nconst normaliseName = (value: string): string =>\n value\n .trim()\n .toLowerCase()\n .replace(/[\\p{P}\\p{S}]/gu, '');\n\n/**\n * GA4's `items` become Meta's `contents`, a closed shape: `id`, `quantity`,\n * `item_price`, `delivery_category`. The rest of a GA4 item is dropped rather\n * than forwarded, because a key Meta does not read inside `contents` looks\n * mapped and arrives nowhere.\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. Everything\n * the host named itself passes through, because a business measures things\n * this vocabulary does not name.\n *\n * `transaction_id` becomes `order_id`, which this surface documents and the\n * pixel does not. It is one of the asymmetries between Meta's two halves an\n * adapter absorbs so nobody else has to.\n */\nfunction customData(data: EventData): Record<string, unknown> {\n const { items, transaction_id: transactionId, ...rest } = data;\n const custom: Record<string, unknown> = { ...rest };\n\n if (transactionId !== undefined) custom.order_id = transactionId;\n if (items) custom.contents = contents(items);\n\n return custom;\n}\n\n/**\n * The heaviest surface of the five vendors: four hashed identifiers, four raw\n * ones, and two cookies forwarded exactly as the pixel wrote them.\n *\n * Built in a fixed order, and hashed one after another rather than in\n * parallel, so that the same input produces the same JSON on every run. Two\n * identical events differing only in key order would be the library inventing\n * something, which is precisely what the conformance suite looks for.\n */\nasync function userData(context: ResolvedContext): Promise<Record<string, string>> {\n const user = context.user ?? {};\n const built: Record<string, string> = {};\n\n const hashed: readonly [string, string | undefined, (value: string) => string][] = [\n ['em', user.email, normaliseEmail],\n ['ph', user.phone, normalisePhone],\n ['fn', user.firstName, normaliseName],\n ['ln', user.lastName, normaliseName],\n ];\n\n for (const [field, raw, normalise] of hashed) {\n if (raw === undefined) continue;\n\n const normalised = normalise(raw);\n // An empty value still hashes to a perfectly valid digest, one that\n // matches nobody and counts as an identifier towards the minimum below.\n if (!normalised) continue;\n\n built[field] = await sha256(normalised);\n }\n\n // Raw by Meta's own instruction. `external_id` is the one it merely\n // *recommends* hashing, and it stays raw here so the value Meta receives is\n // the value the host holds. A host that also puts an external id in its\n // `fbq('init')` should know the pixel hashes what it finds there, so the two\n // surfaces match only if both sides send the same form: pre-hash on both,\n // or on neither. Meta's pages do not state which side should give way.\n if (user.userId !== undefined) built.external_id = user.userId;\n if (context.ip !== undefined) built.client_ip_address = context.ip;\n if (context.userAgent !== undefined) built.client_user_agent = context.userAgent;\n\n // Meta forwards these as-is; they are its own cookies in its own format.\n // Note what is *not* here: an `fbc` synthesised from an `fbclid` in the URL.\n // Meta documents that construction, and it needs a click timestamp the\n // library does not have, so building one would be inventing an identifier.\n const fbp = context.cookies._fbp;\n if (fbp) built.fbp = fbp;\n\n const fbc = context.cookies._fbc;\n if (fbc) built.fbc = fbc;\n\n return built;\n}\n\n/**\n * Meta's Conversions API.\n *\n * @param credentials - The pixel id and an access token.\n * @param config - Event overrides, the default action source, and Meta's own\n * levers: the API version, a test event code, and Limited Data Use.\n * @returns A provider for `createTracking` from `tracklane/server`.\n *\n * @example\n * ```ts\n * import { createTracking, meta } from 'tracklane/server';\n *\n * const { track } = createTracking({\n * providers: [meta({ pixelId: '1234567890', accessToken: process.env.META_TOKEN! })],\n * });\n *\n * await track('purchase', order, {\n * user: { email: order.email },\n * cookies: request.headers.get('cookie'),\n * dedupId: order.id,\n * ip,\n * userAgent,\n * });\n * ```\n */\nexport function meta(\n credentials: MetaServerCredentials,\n config: MetaServerConfig = {},\n): ServerProvider {\n const { pixelId, accessToken } = credentials;\n const { actionSource = 'website', apiVersion = DEFAULT_API_VERSION, testEventCode } = config;\n\n return {\n name: 'meta',\n default: 'passthrough',\n events: { ...CANONICAL_EVENTS, ...config.events },\n\n async track(\n name: string,\n data: EventData,\n context: ResolvedContext,\n report: (message: string) => void,\n ): Promise<void> {\n const user = await userData(context);\n\n if (Object.keys(user).length === 0) {\n // Not withholding on policy grounds: Meta requires at least one\n // `user_data` parameter and none can be invented. Its own page says\n // \"at least one of the following\" over the whole list, so a bare\n // `fbp` qualifies; whether matching is any good with a non-contact\n // identifier alone is Meta's business, not this adapter's to judge.\n // Naming the vendor's own field, so searching for the error leads\n // somewhere.\n throw new Error(\n '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.',\n );\n }\n\n const event: Record<string, unknown> = {\n event_name: name,\n // Seconds, where the context carries milliseconds.\n event_time: Math.floor(context.timestamp / 1000),\n // Required on every event, which is why the factory carries a default\n // rather than letting the request fail.\n action_source: context.source ?? actionSource,\n user_data: user,\n };\n\n // Meta's key is `event_id` here and `eventID` on the pixel, matched\n // together with `event_name`. Never produced by the library.\n if (context.dedupId !== undefined) event.event_id = context.dedupId;\n if (context.url !== undefined) event.event_source_url = context.url;\n\n const custom = customData(data);\n if (Object.keys(custom).length > 0) event.custom_data = custom;\n\n if (config.dataProcessingOptions) {\n // Meta lists these among its *server event* parameters, alongside\n // `event_name` and `user_data`, which is why they sit inside the\n // event. No example on its pages states the level outright.\n event.data_processing_options = config.dataProcessingOptions;\n\n if (config.dataProcessingOptionsCountry !== undefined) {\n event.data_processing_options_country = config.dataProcessingOptionsCountry;\n }\n if (config.dataProcessingOptionsState !== undefined) {\n event.data_processing_options_state = config.dataProcessingOptionsState;\n }\n }\n\n // `context.traits` and `context.consent` map to nothing. Meta has no\n // person-properties slot on this endpoint, and its consent surface is a\n // browser command; the processing marking above is a different concept\n // wearing similar words.\n\n // Form-encoded, which is the shape Meta's own examples post, and it\n // keeps the token out of the query string. Unlike GA4, this vendor\n // offers a body slot for it, so query strings in proxy and APM logs are\n // avoidable here.\n const body = new URLSearchParams({\n data: JSON.stringify([event]),\n access_token: accessToken,\n });\n\n if (testEventCode) body.set('test_event_code', testEventCode);\n\n const response = await fetch(`${GRAPH}/${apiVersion}/${encodeURIComponent(pixelId)}/events`, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body,\n });\n\n if (!response.ok) {\n // The status only. The event carries raw personal data, and no\n // library-owned surface re-emits it into a host's logs.\n throw new Error(`meta: the Conversions API answered ${response.status}`);\n }\n\n // A body that will not parse must not turn a delivered conversion into\n // an error, which would tell a host's retry logic to send it again.\n const result = (await response.json().catch(() => null)) as {\n messages?: unknown[];\n fbtrace_id?: string;\n } | null;\n\n const messages = result?.messages;\n if (Array.isArray(messages) && messages.length > 0) {\n // A caveat about a send that already happened, so it reports rather\n // than throws. The count and the trace id only: Meta's warnings can\n // quote the parameter that upset it, and event contents never reach a\n // host's logs through this library.\n report(\n `accepted with ${messages.length} warning(s) from the Conversions API; read them under fbtrace_id ${result?.fbtrace_id ?? 'unknown'} in Events Manager`,\n );\n }\n },\n };\n}\n","import { assertUniqueProviders, makeReporter, messageOf, resolveEvent } from './dispatch.js';\n\nexport {\n type Ga4ServerConfig,\n type Ga4ServerCredentials,\n ga4,\n} from './providers/ga4.server.js';\nexport {\n type MetaServerConfig,\n type MetaServerCredentials,\n meta,\n} from './providers/meta.server.js';\n\nimport type {\n ActionSource,\n ConsentState,\n EventBinding,\n EventData,\n ProviderDefault,\n TrackingError,\n UserData,\n} from './vocabulary.js';\n\n/**\n * Everything a conversion API needs beyond the event itself: who the person\n * is, when it happened, and where it was observed.\n *\n * None of this is \"what happened\", which is why it does not travel in the\n * event data: putting it there would leak identity into the pass-through\n * event params of the vendors that forward the payload as-is.\n */\nexport interface EventContext {\n /** The person's identity, the server-side counterpart to `identify()` on the browser. */\n user?: UserData;\n /**\n * The request's raw `Cookie` header, or an already-parsed map.\n *\n * These are cookies the vendors' own browser tags set. This library never\n * writes one. On the server each adapter reads the cookies belonging to\n * its own vendor: Meta forwards its value as-is, Google's holds several\n * things at once and has to be opened. That knowledge lives in the\n * adapters; the core only splits `name=value; name=value` into pairs.\n *\n * **It splits, it does not interpret.** Values are handed over exactly as\n * they appear, with no percent-decoding. The cookies these vendors set do\n * not need it; a host reading some other cookie should decode it itself.\n */\n cookies?: string | Record<string, string> | null;\n /** Ties this send to a browser hit. Never invented by the library. */\n dedupId?: string;\n /**\n * When the conversion actually happened. Defaults to now.\n *\n * A webhook arrives after the fact and LinkedIn rejects conversions older\n * than 90 days, so reporting \"now\" for everything would quietly distort\n * attribution.\n */\n timestamp?: number | Date;\n /** Overrides the factory default. Meta requires it on every event. */\n source?: ActionSource;\n /** The page the conversion happened on. Meta's Conversions API reads it as `event_source_url`. */\n url?: string;\n /** The visitor's IP. Meta's Conversions API reads it as one of the identifiers in `user_data`. */\n ip?: string;\n /** The visitor's user agent. Meta's Conversions API reads it alongside `ip`. */\n userAgent?: string;\n /** What is known *about* the person: GA4 user properties, PostHog `$set`. */\n traits?: Record<string, unknown>;\n /**\n * Travels inside the event, for the vendors that document such a field.\n * Today that is GA4's Measurement Protocol, and it reads two of the\n * signals; the others have no slot and ignore it.\n */\n consent?: ConsentState;\n}\n\n/** The context an adapter receives, with the ambiguity resolved. */\nexport interface ResolvedContext extends Omit<EventContext, 'cookies' | 'timestamp'> {\n /** The request's cookies, already split into a `name → value` map. */\n cookies: Record<string, string>;\n /** Epoch milliseconds. Adapters convert to their vendor's spelling. */\n timestamp: number;\n}\n\n/**\n * A destination for server events.\n *\n * `track` may throw, on a non-2xx or where a vendor documents a\n * precondition the event cannot meet. The dispatcher isolates and reports\n * it, so adapters stay honest and loud.\n *\n * `report` is for a caveat about a request that already succeeded. It does\n * not throw: by then the send happened, and throwing would tell a host's\n * retry logic to resend a delivered conversion.\n */\nexport interface ServerProvider<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 /** Sends one event to this vendor's conversion API. May throw, or call `report` for a caveat. */\n track(\n name: Target,\n data: EventData,\n context: ResolvedContext,\n report: (message: string) => void,\n ): Promise<void>;\n}\n\n/** Options accepted by {@link createTracking}. */\nexport interface ServerTrackingOptions {\n /** The vendors to send every event to. Registering the same `name` twice throws. */\n providers: readonly ServerProvider[];\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 ServerTracking {\n /**\n * Sends one event to every configured conversion API.\n *\n * Resolves once every provider has settled and never rejects: one vendor\n * being down is not the caller's failure to handle. Awaiting is about the\n * request surviving a serverless runtime, not about delivery being\n * guaranteed.\n */\n track(name: string, data?: EventData, context?: EventContext): Promise<void>;\n}\n\n/** `name=value; name=value`, the shape of a `Cookie` header. */\nfunction parseCookies(\n cookies: string | Record<string, string> | null | undefined,\n): Record<string, string> {\n if (!cookies) return {};\n if (typeof cookies !== 'string') return cookies;\n\n const parsed: Record<string, string> = {};\n\n for (const pair of cookies.split(';')) {\n const separator = pair.indexOf('=');\n if (separator < 1) continue;\n\n const name = pair.slice(0, separator).trim();\n // Cookie values may contain `=`, so only the first one separates.\n if (name) parsed[name] = pair.slice(separator + 1).trim();\n }\n\n return parsed;\n}\n\n/**\n * Creates the server tracking surface.\n *\n * There is no `identify` here and nothing is held between calls: a server\n * instance is shared by every request, so a retained identity would attach\n * one visitor to another visitor's conversion. Identity travels with the\n * call, which is also what every conversion API expects.\n *\n * There is no `consent` call either. Consent commands are properties of a\n * vendor's on-page tag, and a server request has no tag. The one vendor that\n * accepts consent server-side reads it from the context.\n *\n * @param options - The providers to send to, and an optional error channel.\n * @returns The `track` call.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/server';\n *\n * const { track } = createTracking({\n * providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret })],\n * });\n *\n * await track('purchase', order, { cookies: request.headers.get('cookie') });\n * ```\n */\nexport function createTracking(options: ServerTrackingOptions): ServerTracking {\n const { providers } = options;\n assertUniqueProviders(providers);\n\n const report = makeReporter(options.onError);\n\n return {\n async track(name, data = {}, context = {}) {\n const { cookies, timestamp, ...rest } = context;\n const resolved: ResolvedContext = {\n ...rest,\n cookies: parseCookies(cookies),\n timestamp: timestamp instanceof Date ? timestamp.getTime() : (timestamp ?? Date.now()),\n };\n\n const sends = providers.map(async (provider) => {\n const target = resolveEvent(provider, name);\n if (target === undefined) return;\n\n try {\n await provider.track(target, data, resolved, (message) => {\n report({ provider: provider.name, event: name, severity: 'warning', message });\n });\n } catch (cause) {\n report({\n provider: provider.name,\n event: name,\n severity: 'error',\n message: messageOf(cause),\n cause,\n });\n }\n });\n\n await Promise.all(sends);\n },\n };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tracklane",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "One interface between your application and every tool that receives events
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "One interface between your application and every tool that receives events, for the browser and server. GA4 and Meta today; LinkedIn, PostHog and X next.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"analytics",
|
|
7
7
|
"tracking",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"author": "Bruno Bertolini <dev@brunobertolini.com>",
|
|
18
|
-
"homepage": "https://
|
|
18
|
+
"homepage": "https://tracklane.codar.me",
|
|
19
19
|
"repository": {
|
|
20
20
|
"type": "git",
|
|
21
21
|
"url": "git+https://github.com/brunobertolini/tracklane.git",
|
|
@@ -51,6 +51,15 @@
|
|
|
51
51
|
"access": "public",
|
|
52
52
|
"provenance": true
|
|
53
53
|
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@arethetypeswrong/cli": "0.18.5",
|
|
56
|
+
"@types/node": "^26.1.2",
|
|
57
|
+
"@vitest/coverage-v8": "4.1.10",
|
|
58
|
+
"publint": "0.3.22",
|
|
59
|
+
"tsdown": "0.22.14",
|
|
60
|
+
"typescript": "^6.0.3",
|
|
61
|
+
"vitest": "4.1.10"
|
|
62
|
+
},
|
|
54
63
|
"scripts": {
|
|
55
64
|
"build": "tsdown",
|
|
56
65
|
"dev": "tsdown --watch",
|
|
@@ -58,14 +67,5 @@
|
|
|
58
67
|
"test:coverage": "vitest run --coverage",
|
|
59
68
|
"typecheck": "tsc --noEmit",
|
|
60
69
|
"package:check": "publint --strict && attw --pack . --profile esm-only"
|
|
61
|
-
},
|
|
62
|
-
"devDependencies": {
|
|
63
|
-
"@arethetypeswrong/cli": "0.18.5",
|
|
64
|
-
"@types/node": "catalog:",
|
|
65
|
-
"@vitest/coverage-v8": "4.1.10",
|
|
66
|
-
"publint": "0.3.22",
|
|
67
|
-
"tsdown": "0.22.14",
|
|
68
|
-
"typescript": "catalog:",
|
|
69
|
-
"vitest": "4.1.10"
|
|
70
70
|
}
|
|
71
|
-
}
|
|
71
|
+
}
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { c as UserData, i as EventData, n as ConsentState, o as ProviderDefault, r as EventBinding, s as TrackingError } from "./vocabulary-ByyXHVWd.js";
|
|
2
|
-
//#region src/providers/ga4.browser.d.ts
|
|
3
|
-
/** Optional configuration for the GA4 browser provider. */
|
|
4
|
-
interface Ga4BrowserConfig {
|
|
5
|
-
/**
|
|
6
|
-
* How this property spells each canonical event. Rarely needed: the
|
|
7
|
-
* canonical vocabulary already is GA4's.
|
|
8
|
-
*/
|
|
9
|
-
events?: Record<string, EventBinding<string>>;
|
|
10
|
-
}
|
|
11
|
-
/**
|
|
12
|
-
* Google Analytics 4 in the browser, through `gtag.js`.
|
|
13
|
-
*
|
|
14
|
-
* **This provider does not load Google's tag.** The tag belongs on the page,
|
|
15
|
-
* installed the way Google documents it — a snippet in the HTML, or a tag
|
|
16
|
-
* manager. This library talks to the tag that is already there; it never
|
|
17
|
-
* injects a third-party script into your page, and it never issues the
|
|
18
|
-
* property configuration that would emit a second page view.
|
|
19
|
-
*
|
|
20
|
-
* Which also means the initial consent declaration is not ours to make:
|
|
21
|
-
* Google requires it before the property is configured, and the snippet that
|
|
22
|
-
* configures the property is yours. Use {@link ConsentState} through
|
|
23
|
-
* `consent()` for changes after that.
|
|
24
|
-
*
|
|
25
|
-
* @param measurementId - The `G-` id of the data stream, matching the tag on
|
|
26
|
-
* the page.
|
|
27
|
-
* @param config - Rarely needed event name overrides.
|
|
28
|
-
* @returns A provider for `createTracking` from `tracklane/browser`.
|
|
29
|
-
*
|
|
30
|
-
* @example
|
|
31
|
-
* ```ts
|
|
32
|
-
* import { createTracking, ga4 } from 'tracklane/browser';
|
|
33
|
-
*
|
|
34
|
-
* // Google's own snippet is already in the page.
|
|
35
|
-
* const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });
|
|
36
|
-
*
|
|
37
|
-
* track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });
|
|
38
|
-
* ```
|
|
39
|
-
*/
|
|
40
|
-
declare function ga4(measurementId: string, config?: Ga4BrowserConfig): BrowserProvider;
|
|
41
|
-
//#endregion
|
|
42
|
-
//#region src/browser.d.ts
|
|
43
|
-
/**
|
|
44
|
-
* Everything the browser needs beyond the event itself.
|
|
45
|
-
*
|
|
46
|
-
* There is one field, and that is the point: in the browser each vendor tag
|
|
47
|
-
* reads its own cookies and holds its own session, so the only thing left
|
|
48
|
-
* for the host to supply is the value that ties this hit to a server-side
|
|
49
|
-
* one.
|
|
50
|
-
*/
|
|
51
|
-
interface TrackOptions {
|
|
52
|
-
/**
|
|
53
|
-
* The value that lets a vendor recognise this hit and a server-side hit as
|
|
54
|
-
* one event. The library never invents it — the browser and the server do
|
|
55
|
-
* not share a process, so only a value the host already owns (an order id)
|
|
56
|
-
* can match on both sides.
|
|
57
|
-
*/
|
|
58
|
-
dedupId?: string;
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* A destination for browser events.
|
|
62
|
-
*
|
|
63
|
-
* This is the whole contract, and it is what a provider written outside this
|
|
64
|
-
* package implements — the ones shipped here get no shortcut.
|
|
65
|
-
*/
|
|
66
|
-
interface BrowserProvider<Target = string> {
|
|
67
|
-
/** The vendor's fixed name. Registering one twice is an error. */
|
|
68
|
-
name: string;
|
|
69
|
-
/** What happens to a canonical event this vendor has no binding for. */
|
|
70
|
-
default: ProviderDefault;
|
|
71
|
-
/** How this vendor spells each canonical event. */
|
|
72
|
-
events?: Record<string, EventBinding<Target>>;
|
|
73
|
-
/** Loads the vendor tag. Runs once, at creation. */
|
|
74
|
-
install?(): void | Promise<void>;
|
|
75
|
-
track(name: Target, data: EventData, options: TrackOptions): void;
|
|
76
|
-
/** Absent where the vendor keeps no user of its own. */
|
|
77
|
-
identify?(user: UserData, traits?: Record<string, unknown>): void;
|
|
78
|
-
/** Absent where the vendor documents no consent command. */
|
|
79
|
-
consent?(command: "default" | "update", state: ConsentState): void;
|
|
80
|
-
}
|
|
81
|
-
/** Options accepted by {@link createTracking}. */
|
|
82
|
-
interface BrowserTrackingOptions {
|
|
83
|
-
providers: readonly BrowserProvider[];
|
|
84
|
-
/**
|
|
85
|
-
* The one diagnostic channel. Without it the library is silent, exactly as
|
|
86
|
-
* a hand-written vendor call is.
|
|
87
|
-
*/
|
|
88
|
-
onError?: (error: TrackingError) => void;
|
|
89
|
-
}
|
|
90
|
-
/** What {@link createTracking} returns. */
|
|
91
|
-
interface BrowserTracking {
|
|
92
|
-
/** Sends one event to every configured vendor. Never throws. */
|
|
93
|
-
track(name: string, data?: EventData, options?: TrackOptions): void;
|
|
94
|
-
/** Forwards the user to the vendors that hold one, and to nobody else. */
|
|
95
|
-
identify(user: UserData, traits?: Record<string, unknown>): void;
|
|
96
|
-
/**
|
|
97
|
-
* Forwards a consent declaration to every vendor that documents a command
|
|
98
|
-
* for it. It never decides, stores or withholds anything.
|
|
99
|
-
*/
|
|
100
|
-
consent(command: "default" | "update", state: ConsentState): void;
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* Creates the browser tracking surface.
|
|
104
|
-
*
|
|
105
|
-
* Providers install eagerly, which is what pasting a vendor snippet does and
|
|
106
|
-
* what lets each vendor record its own initial page view. Every vendor
|
|
107
|
-
* shipped here queues its own commands until its script lands, so nothing is
|
|
108
|
-
* lost to loading and this library needs no queue of its own.
|
|
109
|
-
*
|
|
110
|
-
* @param options - The providers to send to, and an optional error channel.
|
|
111
|
-
* @returns The `track`, `identify` and `consent` calls.
|
|
112
|
-
*
|
|
113
|
-
* @example
|
|
114
|
-
* ```ts
|
|
115
|
-
* import { createTracking, ga4 } from 'tracklane/browser';
|
|
116
|
-
*
|
|
117
|
-
* const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });
|
|
118
|
-
* track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });
|
|
119
|
-
* ```
|
|
120
|
-
*/
|
|
121
|
-
declare function createTracking(options: BrowserTrackingOptions): BrowserTracking;
|
|
122
|
-
//#endregion
|
|
123
|
-
export { createTracking as a, TrackOptions as i, BrowserTracking as n, Ga4BrowserConfig as o, BrowserTrackingOptions as r, ga4 as s, BrowserProvider as t };
|
|
124
|
-
//# sourceMappingURL=browser-BQNJ4qUq.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dispatch-DK2GBnkt.js","names":[],"sources":["../src/dispatch.ts"],"sourcesContent":["import type { EventBinding, ProviderDefault, TrackingError } from './vocabulary.js';\n\n/** The part of a provider both libraries resolve event names against. */\ninterface Resolvable<Target> {\n name: string;\n default: ProviderDefault;\n events?: Record<string, EventBinding<Target>>;\n}\n\n/**\n * Registering one vendor twice is a hard error rather than a merge: there is\n * nothing a second registration could say that the first cannot, and\n * accepting both would double every event to that vendor — a defect that\n * surfaces weeks later as inflated conversions.\n */\nexport function assertUniqueProviders(providers: readonly { name: string }[]): void {\n const seen = new Set<string>();\n\n for (const provider of providers) {\n if (seen.has(provider.name)) {\n throw new Error(\n `tracklane: provider \"${provider.name}\" is registered twice. One configuration per vendor.`,\n );\n }\n seen.add(provider.name);\n }\n}\n\n/**\n * Resolves how a vendor spells a canonical event, or `undefined` when\n * nothing should be sent.\n *\n * Not sending is a legitimate outcome rather than a failure: `null` is the\n * host saying \"never send this event here\", and `ignore` is a vendor whose\n * event identity is minted per account in its own dashboard, so there is no\n * name that could possibly be guessed.\n */\nexport function resolveEvent<Target>(\n provider: Resolvable<Target>,\n name: string,\n): Target | undefined {\n const events = provider.events;\n // `hasOwn`, not a plain lookup: every object inherits `constructor` and\n // `toString`, so an event named either would otherwise resolve to a\n // function off the prototype chain.\n const binding = events && Object.hasOwn(events, name) ? events[name] : undefined;\n\n if (binding === null) return undefined;\n if (binding !== undefined) return binding;\n if (provider.default === 'ignore') return undefined;\n\n return name as Target;\n}\n\n/**\n * Builds the reporting channel. A host that passes no `onError` gets\n * silence, which is what a hand-written vendor call gives it.\n */\nexport function makeReporter(\n onError: ((error: TrackingError) => void) | undefined,\n): (error: TrackingError) => void {\n if (!onError) return () => {};\n\n return (error) => {\n // A throwing reporter must not take the dispatch down with it, or a\n // host's broken logger becomes a broken checkout.\n try {\n onError(error);\n } catch {\n /* the reporter is the last line; there is nowhere left to report to */\n }\n };\n}\n\n/** Normalises whatever a provider threw into a reportable message. */\nexport function messageOf(cause: unknown): string {\n return cause instanceof Error ? cause.message : String(cause);\n}\n"],"mappings":";;;;;;;AAeA,SAAgB,sBAAsB,WAA8C;CAClF,MAAM,uBAAO,IAAI,IAAY;CAE7B,KAAK,MAAM,YAAY,WAAW;EAChC,IAAI,KAAK,IAAI,SAAS,IAAI,GACxB,MAAM,IAAI,MACR,wBAAwB,SAAS,KAAK,qDACxC;EAEF,KAAK,IAAI,SAAS,IAAI;CACxB;AACF;;;;;;;;;;AAWA,SAAgB,aACd,UACA,MACoB;CACpB,MAAM,SAAS,SAAS;CAIxB,MAAM,UAAU,UAAU,OAAO,OAAO,QAAQ,IAAI,IAAI,OAAO,QAAQ,KAAA;CAEvE,IAAI,YAAY,MAAM,OAAO,KAAA;CAC7B,IAAI,YAAY,KAAA,GAAW,OAAO;CAClC,IAAI,SAAS,YAAY,UAAU,OAAO,KAAA;CAE1C,OAAO;AACT;;;;;AAMA,SAAgB,aACd,SACgC;CAChC,IAAI,CAAC,SAAS,aAAa,CAAC;CAE5B,QAAQ,UAAU;EAGhB,IAAI;GACF,QAAQ,KAAK;EACf,QAAQ,CAER;CACF;AACF;;AAGA,SAAgB,UAAU,OAAwB;CAChD,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC9D"}
|