tracklane 0.6.0 → 0.7.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/dist/server.js CHANGED
@@ -1,531 +1,2 @@
1
- import { t as VendorResponseError } from "./errors-DLRGPWMj.js";
2
- import { i as resolveEvent, n as makeReporter, r as messageOf, t as assertUniqueProviders } from "./dispatch-BLBoCn-b.js";
3
- //#region src/providers/ga4.server.ts
4
- const ENDPOINT = "https://www.google-analytics.com/mp/collect";
5
- /**
6
- * The visitor cookie holds `GA1.<depth>.<client_id>`, and the client id
7
- * itself contains a dot, so the identifier is the last two segments, which
8
- * is what survives Google's varying prefix depth.
9
- *
10
- * The value must be the cookie as the browser wrote it. A host passing a
11
- * parsed map passes cookie values, not values it took apart itself.
12
- */
13
- function visitorId(cookies) {
14
- const raw = cookies._ga;
15
- if (!raw) return void 0;
16
- const parts = raw.split(".");
17
- if (parts.length < 4) return void 0;
18
- return parts.slice(-2).join(".");
19
- }
20
- /**
21
- * The per-container cookie carries the session, in one of two layouts.
22
- *
23
- * Verified against Google's live tag on 2026-07-31 rather than assumed:
24
- *
25
- * - `GS2.1.s1785511401$o1$g0$t1785511401$j60$l0$h0`, the current one. The
26
- * third dot-segment packs several fields joined by `$`, and the session is
27
- * the first of them, prefixed with `s`.
28
- * - `GS1.1.1785511401.1.1.…`, the older one, where the third dot-segment is
29
- * the session on its own.
30
- *
31
- * Reading the third segment blindly works for the old layout and returns the
32
- * whole `s…$o1$g0$…` blob for the new one, which the vendor would accept and
33
- * silently fail to match. Hence the two shapes, and hence `undefined` rather
34
- * than a guess when neither fits.
35
- */
36
- function sessionId(cookies, measurementId) {
37
- const segment = cookies[`_ga_${measurementId.replace(/^G-/, "")}`]?.split(".")[2];
38
- if (!segment) return void 0;
39
- const packed = /^s(\d+)/.exec(segment);
40
- if (packed) return packed[1];
41
- return /^\d+$/.test(segment) ? segment : void 0;
42
- }
43
- /**
44
- * The Measurement Protocol documents exactly two consent fields, and spells
45
- * their values in upper case where the browser spells them in lower, one of
46
- * the small asymmetries an adapter absorbs so nobody else has to.
47
- */
48
- function consentBody(state) {
49
- if (!state) return void 0;
50
- const body = {};
51
- if (state.ad_user_data) body.ad_user_data = state.ad_user_data.toUpperCase();
52
- if (state.ad_personalization) body.ad_personalization = state.ad_personalization.toUpperCase();
53
- return Object.keys(body).length > 0 ? body : void 0;
54
- }
55
- /**
56
- * Google Analytics 4 on the server, through the Measurement Protocol.
57
- *
58
- * @param credentials - The measurement id and API secret.
59
- * @param config - Rarely needed event name overrides.
60
- * @returns A provider for `createTracking` from `tracklane/server`.
61
- *
62
- * @example
63
- * ```ts
64
- * import { createTracking, ga4 } from 'tracklane/server';
65
- *
66
- * const { track } = createTracking({
67
- * providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret: process.env.GA4_SECRET! })],
68
- * });
69
- *
70
- * await track('purchase', order, { cookies: request.headers.get('cookie') });
71
- * ```
72
- */
73
- function ga4(credentials, config = {}) {
74
- const { measurementId, apiSecret } = credentials;
75
- return {
76
- name: "ga4",
77
- default: "passthrough",
78
- ...config.events ? { events: config.events } : {},
79
- async track(name, data, context, report) {
80
- const clientId = visitorId(context.cookies);
81
- 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.");
82
- const session = sessionId(context.cookies, measurementId);
83
- if (!session) report("no session cookie (_ga_<container>): Google will accept this event and it will not appear in any report");
84
- const params = { ...data };
85
- if (session) {
86
- params.session_id = session;
87
- params.engagement_time_msec = 1;
88
- }
89
- const body = {
90
- client_id: clientId,
91
- timestamp_micros: context.timestamp * 1e3,
92
- events: [{
93
- name,
94
- params
95
- }]
96
- };
97
- if (context.user?.userId !== void 0) body.user_id = context.user.userId;
98
- if (context.traits) body.user_properties = Object.fromEntries(Object.entries(context.traits).map(([name, value]) => [name, { value }]));
99
- const consent = consentBody(context.consent);
100
- if (consent) body.consent = consent;
101
- const url = `${ENDPOINT}?measurement_id=${encodeURIComponent(measurementId)}&api_secret=${encodeURIComponent(apiSecret)}`;
102
- const response = await fetch(url, {
103
- method: "POST",
104
- headers: { "content-type": "application/json" },
105
- body: JSON.stringify(body)
106
- });
107
- if (!response.ok) {
108
- const body = await response.text().catch(() => "");
109
- throw new VendorResponseError(`ga4: the Measurement Protocol answered ${response.status}`, {
110
- provider: "ga4",
111
- status: response.status,
112
- body
113
- });
114
- }
115
- }
116
- };
117
- }
118
- //#endregion
119
- //#region src/providers/meta.server.ts
120
- const GRAPH = "https://graph.facebook.com";
121
- /**
122
- * Pinned on purpose. A Graph URL with no version resolves to the *oldest*
123
- * version still available, which silently changes what the endpoint accepts
124
- * the day Meta drops it.
125
- */
126
- const DEFAULT_API_VERSION = "v25.0";
127
- /**
128
- * How Meta spells the canonical vocabulary, which is GA4's. Kept in step with
129
- * `meta.browser.ts` by hand: the two halves are two libraries, and neither
130
- * imports the other.
131
- */
132
- const CANONICAL_EVENTS = {
133
- page_view: "PageView",
134
- view_item: "ViewContent",
135
- search: "Search",
136
- add_to_cart: "AddToCart",
137
- add_to_wishlist: "AddToWishlist",
138
- begin_checkout: "InitiateCheckout",
139
- add_payment_info: "AddPaymentInfo",
140
- purchase: "Purchase",
141
- sign_up: "CompleteRegistration",
142
- generate_lead: "Lead"
143
- };
144
- /**
145
- * Web Crypto rather than `node:crypto`, so the built server bundle stays
146
- * platform-neutral: this adapter is called from Cloudflare Workers, Deno and
147
- * Vercel's edge runtime as often as from a Node process, and a `node:` import
148
- * would rule those out for no gain.
149
- */
150
- async function sha256(value) {
151
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
152
- return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
153
- }
154
- /**
155
- * **Why the adapter hashes and does not ask the host to.**
156
- *
157
- * This looks like the library transforming data, and the cutting rule forbids
158
- * that. It is not. Meta does not define a field called "email" that happens
159
- * to want a hash: it defines `em` *as* the SHA-256 of the trimmed, lowercased
160
- * address, the same way it defines `event_time` as seconds rather than
161
- * milliseconds. Writing this line by hand you write
162
- * `sha256(email.trim().toLowerCase())`, and the adapter replicates exactly
163
- * that. Normalisation lives with the field it belongs to.
164
- *
165
- * The alternative is worse and is where integrations demonstrably go wrong:
166
- * three vendors want SHA-256 under three different normalisation rules, so a
167
- * host hashing on its own side has to hash the same address three ways and
168
- * discovers a mistake only as a quietly poor match rate.
169
- */
170
- const normaliseEmail = (value) => value.trim().toLowerCase();
171
- /**
172
- * "Remove symbols, letters, and any leading zeros." The country code is not
173
- * added here: Meta wants one and the library does not know it, and guessing a
174
- * country onto a phone number invents an identifier for a different person.
175
- */
176
- const normalisePhone = (value) => value.replace(/\D/g, "").replace(/^0+/, "");
177
- /** "Lowercase only with no punctuation." Spaces and accents survive; Meta reads UTF-8. */
178
- const normaliseName = (value) => value.trim().toLowerCase().replace(/[\p{P}\p{S}]/gu, "");
179
- /**
180
- * GA4's `items` become Meta's `contents`, a closed shape: `id`, `quantity`,
181
- * `item_price`, `delivery_category`. The rest of a GA4 item is dropped rather
182
- * than forwarded, because a key Meta does not read inside `contents` looks
183
- * mapped and arrives nowhere.
184
- */
185
- function contents(items) {
186
- return items.map((item) => {
187
- const content = {};
188
- if (item.item_id !== void 0) content.id = item.item_id;
189
- if (item.quantity !== void 0) content.quantity = item.quantity;
190
- if (item.price !== void 0) content.item_price = item.price;
191
- return content;
192
- });
193
- }
194
- /**
195
- * `value` and `currency` are spelled identically by both vendors. Everything
196
- * the host named itself passes through, because a business measures things
197
- * this vocabulary does not name.
198
- *
199
- * `transaction_id` becomes `order_id`, which this surface documents and the
200
- * pixel does not. It is one of the asymmetries between Meta's two halves an
201
- * adapter absorbs so nobody else has to.
202
- */
203
- function customData(data) {
204
- const { items, transaction_id: transactionId, ...rest } = data;
205
- const custom = { ...rest };
206
- if (transactionId !== void 0) custom.order_id = transactionId;
207
- if (items) custom.contents = contents(items);
208
- return custom;
209
- }
210
- /**
211
- * The heaviest surface of the five vendors: four hashed identifiers, four raw
212
- * ones, and two cookies forwarded exactly as the pixel wrote them.
213
- *
214
- * Built in a fixed order, and hashed one after another rather than in
215
- * parallel, so that the same input produces the same JSON on every run. Two
216
- * identical events differing only in key order would be the library inventing
217
- * something, which is precisely what the conformance suite looks for.
218
- */
219
- async function userData(context) {
220
- const user = context.user ?? {};
221
- const built = {};
222
- const hashed = [
223
- [
224
- "em",
225
- user.email,
226
- normaliseEmail
227
- ],
228
- [
229
- "ph",
230
- user.phone,
231
- normalisePhone
232
- ],
233
- [
234
- "fn",
235
- user.firstName,
236
- normaliseName
237
- ],
238
- [
239
- "ln",
240
- user.lastName,
241
- normaliseName
242
- ]
243
- ];
244
- for (const [field, raw, normalise] of hashed) {
245
- if (raw === void 0) continue;
246
- const normalised = normalise(raw);
247
- if (!normalised) continue;
248
- built[field] = await sha256(normalised);
249
- }
250
- if (user.userId !== void 0) built.external_id = user.userId;
251
- if (context.ip !== void 0) built.client_ip_address = context.ip;
252
- if (context.userAgent !== void 0) built.client_user_agent = context.userAgent;
253
- const fbp = context.cookies._fbp;
254
- if (fbp) built.fbp = fbp;
255
- const fbc = context.cookies._fbc;
256
- if (fbc) built.fbc = fbc;
257
- return built;
258
- }
259
- /**
260
- * Meta's Conversions API.
261
- *
262
- * @param credentials - The pixel id and an access token.
263
- * @param config - Event overrides, the default action source, and Meta's own
264
- * levers: the API version, a test event code, and Limited Data Use.
265
- * @returns A provider for `createTracking` from `tracklane/server`.
266
- *
267
- * @example
268
- * ```ts
269
- * import { createTracking, meta } from 'tracklane/server';
270
- *
271
- * const { track } = createTracking({
272
- * providers: [meta({ pixelId: '1234567890', accessToken: process.env.META_TOKEN! })],
273
- * });
274
- *
275
- * await track('purchase', order, {
276
- * user: { email: order.email },
277
- * cookies: request.headers.get('cookie'),
278
- * dedupId: order.id,
279
- * ip,
280
- * userAgent,
281
- * });
282
- * ```
283
- */
284
- function meta(credentials, config = {}) {
285
- const { pixelId, accessToken } = credentials;
286
- const { actionSource = "website", apiVersion = DEFAULT_API_VERSION, testEventCode } = config;
287
- return {
288
- name: "meta",
289
- default: "passthrough",
290
- events: {
291
- ...CANONICAL_EVENTS,
292
- ...config.events
293
- },
294
- async track(name, data, context, report) {
295
- const user = await userData(context);
296
- 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.");
297
- const event = {
298
- event_name: name,
299
- event_time: Math.floor(context.timestamp / 1e3),
300
- action_source: context.source ?? actionSource,
301
- user_data: user
302
- };
303
- if (context.dedupId !== void 0) event.event_id = context.dedupId;
304
- if (context.url !== void 0) event.event_source_url = context.url;
305
- const custom = customData(data);
306
- if (Object.keys(custom).length > 0) event.custom_data = custom;
307
- if (config.dataProcessingOptions) {
308
- event.data_processing_options = config.dataProcessingOptions;
309
- if (config.dataProcessingOptionsCountry !== void 0) event.data_processing_options_country = config.dataProcessingOptionsCountry;
310
- if (config.dataProcessingOptionsState !== void 0) event.data_processing_options_state = config.dataProcessingOptionsState;
311
- }
312
- const body = new URLSearchParams({
313
- data: JSON.stringify([event]),
314
- access_token: accessToken
315
- });
316
- if (testEventCode) body.set("test_event_code", testEventCode);
317
- const response = await fetch(`${GRAPH}/${apiVersion}/${encodeURIComponent(pixelId)}/events`, {
318
- method: "POST",
319
- headers: { "content-type": "application/x-www-form-urlencoded" },
320
- body
321
- });
322
- if (!response.ok) {
323
- const body = await response.text().catch(() => "");
324
- throw new VendorResponseError(`meta: the Conversions API answered ${response.status}`, {
325
- provider: "meta",
326
- status: response.status,
327
- body
328
- });
329
- }
330
- const result = await response.json().catch(() => null);
331
- const messages = result?.messages;
332
- 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`);
333
- }
334
- };
335
- }
336
- //#endregion
337
- //#region src/providers/posthog.server.ts
338
- const DEFAULT_HOST = "https://us.i.posthog.com";
339
- /**
340
- * The distinct_id lives inside a cookie posthog-js writes by default, named
341
- * after this project's own api key: `ph_<api_key>_posthog`. Its value is
342
- * `encodeURIComponent(JSON.stringify({ distinct_id, ... }))`, so it has to be
343
- * decoded before it parses as JSON.
344
- *
345
- * Verified against a live project rather than read off the SDK's source: the
346
- * cookie was percent-encoded, held `distinct_id` alongside `$device_id` and
347
- * `$user_state`, and the id parsed out of it matched what the page's own
348
- * `posthog.get_distinct_id()` returned. A server event sent with it landed on
349
- * the same person the browser had created, which is the whole point.
350
- *
351
- * This is only the anonymous fallback. It is not read when the caller already
352
- * knows who the visitor is; see the comment in `track` for why.
353
- */
354
- function anonymousId(cookies, apiKey) {
355
- const raw = cookies[`ph_${apiKey}_posthog`];
356
- if (!raw) return void 0;
357
- try {
358
- const parsed = JSON.parse(decodeURIComponent(raw));
359
- return typeof parsed.distinct_id === "string" ? parsed.distinct_id : void 0;
360
- } catch {
361
- return;
362
- }
363
- }
364
- /**
365
- * PostHog deduplicates on `uuid`, and the field holds a UUID rather than any
366
- * string: an invalid one is documented as dropped on ingestion, and has been
367
- * reported answering 400 instead. Since `dedupId` is whatever the host chose
368
- * — Meta's `event_id` takes anything, and this library's own example is an
369
- * order id — forwarding it unchecked would turn a duplicated purchase into no
370
- * purchase at all.
371
- *
372
- * Accepts any RFC 4122 layout rather than v4 alone, because v7 is what a
373
- * system minting ids today tends to produce.
374
- */
375
- const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
376
- /**
377
- * PostHog on the server, through the Capture API.
378
- *
379
- * **Deduplicating a retried webhook takes two things, not one.** `dedupId`
380
- * becomes PostHog's `uuid`, and PostHog collapses events sharing `uuid`, event
381
- * name, timestamp and `distinct_id` — so a `timestamp` left to default to the
382
- * clock differs on the retry and nothing is deduplicated. Pin it to a value
383
- * derived from the order, as below. The `dedupId` itself must be a UUID:
384
- * anything else is reported through `onError` and sent without the field,
385
- * because PostHog rejects other shapes rather than ignoring them.
386
- *
387
- * Deduplication is also eventual, happening during a background merge, so both
388
- * rows are visible for a while and a test that counts immediately will fail.
389
- *
390
- * @param credentials - The project API key, and the ingestion host for
391
- * anything other than PostHog's default US Cloud instance.
392
- * @param config - Rarely needed event name overrides.
393
- * @returns A provider for `createTracking` from `tracklane/server`.
394
- *
395
- * @example
396
- * ```ts
397
- * import { createTracking, posthog } from 'tracklane/server';
398
- *
399
- * const { track } = createTracking({
400
- * providers: [posthog({ apiKey: process.env.POSTHOG_API_KEY! })],
401
- * });
402
- *
403
- * // Both fields come from the order, so the payment provider's second
404
- * // delivery of the same webhook produces the same event.
405
- * await track('purchase', order, {
406
- * user: { userId: order.userId },
407
- * cookies: request.headers.get('cookie'),
408
- * dedupId: order.eventId, // a UUID your checkout minted with the order
409
- * timestamp: order.paidAt,
410
- * });
411
- * ```
412
- */
413
- function posthog(credentials, config = {}) {
414
- const { apiKey, host = DEFAULT_HOST } = credentials;
415
- return {
416
- name: "posthog",
417
- default: "passthrough",
418
- ...config.events ? { events: config.events } : {},
419
- async track(name, data, context, report) {
420
- const id = context.user?.userId ?? anonymousId(context.cookies, apiKey);
421
- if (!id) throw new Error("posthog: cannot resolve distinct_id — no user.userId in context and no ph_<api_key>_posthog cookie among the cookies passed in context. Pass user.userId once the visitor is known, or ensure PostHog's browser tag has set its cookie first.");
422
- const properties = { ...data };
423
- if (context.traits) properties.$set = context.traits;
424
- const dedup = {};
425
- if (context.dedupId !== void 0) if (UUID.test(context.dedupId)) dedup.uuid = context.dedupId;
426
- else report("dedupId is not a UUID, so it was not forwarded as uuid: PostHog deduplicates on that field and rejects other shapes, so this event can duplicate on a retry");
427
- const body = {
428
- api_key: apiKey,
429
- event: name,
430
- distinct_id: id,
431
- ...dedup,
432
- properties,
433
- timestamp: new Date(context.timestamp).toISOString()
434
- };
435
- const response = await fetch(`${host}/i/v0/e/`, {
436
- method: "POST",
437
- headers: { "content-type": "application/json" },
438
- body: JSON.stringify(body)
439
- });
440
- if (!response.ok) {
441
- const body = await response.text().catch(() => "");
442
- throw new VendorResponseError(`posthog: the capture endpoint answered ${response.status}`, {
443
- provider: "posthog",
444
- status: response.status,
445
- body
446
- });
447
- }
448
- }
449
- };
450
- }
451
- //#endregion
452
- //#region src/server.ts
453
- /** `name=value; name=value`, the shape of a `Cookie` header. */
454
- function parseCookies(cookies) {
455
- if (!cookies) return {};
456
- if (typeof cookies !== "string") return cookies;
457
- const parsed = {};
458
- for (const pair of cookies.split(";")) {
459
- const separator = pair.indexOf("=");
460
- if (separator < 1) continue;
461
- const name = pair.slice(0, separator).trim();
462
- if (name) parsed[name] = pair.slice(separator + 1).trim();
463
- }
464
- return parsed;
465
- }
466
- /**
467
- * Creates the server tracking surface.
468
- *
469
- * There is no `identify` here and nothing is held between calls: a server
470
- * instance is shared by every request, so a retained identity would attach
471
- * one visitor to another visitor's conversion. Identity travels with the
472
- * call, which is also what every conversion API expects.
473
- *
474
- * There is no `consent` call either. Consent commands are properties of a
475
- * vendor's on-page tag, and a server request has no tag. The one vendor that
476
- * accepts consent server-side reads it from the context.
477
- *
478
- * @param options - The providers to send to, and an optional error channel.
479
- * @returns The `track` call.
480
- *
481
- * @example
482
- * ```ts
483
- * import { createTracking, ga4 } from 'tracklane/server';
484
- *
485
- * const { track } = createTracking({
486
- * providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret })],
487
- * });
488
- *
489
- * await track('purchase', order, { cookies: request.headers.get('cookie') });
490
- * ```
491
- */
492
- function createTracking(options) {
493
- const { providers } = options;
494
- assertUniqueProviders(providers);
495
- const report = makeReporter(options.onError);
496
- return { async track(name, data = {}, context = {}) {
497
- const { cookies, timestamp, ...rest } = context;
498
- const resolved = {
499
- ...rest,
500
- cookies: parseCookies(cookies),
501
- timestamp: timestamp instanceof Date ? timestamp.getTime() : timestamp ?? Date.now()
502
- };
503
- const sends = providers.map(async (provider) => {
504
- const target = resolveEvent(provider, name);
505
- if (target === void 0) return;
506
- try {
507
- await provider.track(target, data, resolved, (message) => {
508
- report({
509
- provider: provider.name,
510
- event: name,
511
- severity: "warning",
512
- message
513
- });
514
- });
515
- } catch (cause) {
516
- report({
517
- provider: provider.name,
518
- event: name,
519
- severity: "error",
520
- message: messageOf(cause),
521
- cause
522
- });
523
- }
524
- });
525
- await Promise.all(sends);
526
- } };
527
- }
528
- //#endregion
529
- export { createTracking, ga4, meta, posthog };
530
-
531
- //# sourceMappingURL=server.js.map
1
+ import { a as ga4, i as linkedin, n as posthog, r as meta, t as createTracking } from "./server-DnP7RtEn.js";
2
+ export { createTracking, ga4, linkedin, meta, posthog };
@@ -72,4 +72,4 @@ function isVendorResponseError(value) {
72
72
  //#endregion
73
73
  export { isVendorResponseError as n, VendorResponseError as t };
74
74
 
75
- //# sourceMappingURL=errors-DLRGPWMj.js.map
75
+ //# sourceMappingURL=src-DLRGPWMj.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"src-DLRGPWMj.js","names":[],"sources":["../src/errors.ts"],"sourcesContent":["const BRAND = Symbol.for('tracklane.VendorResponseError');\n\n/** The vendor's own answer to a refusal, capped so it cannot grow without bound. */\nconst BODY_CAP = 2048;\nconst TRUNCATION_SUFFIX = '…[truncated]';\n\nfunction capBody(body: string): string {\n if (body.length <= BODY_CAP) return body;\n return `${body.slice(0, BODY_CAP - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}`;\n}\n\n/**\n * Thrown by a server provider when the vendor's endpoint answers with a\n * non-2xx status. The message stays exactly what the provider composed,\n * free of the payload, per `TrackingError`'s guarantee, and the vendor's own\n * answer travels separately on `status` and `body`, for a host that asks for\n * it via `onError` rather than a host that logs by default.\n *\n * See ADR-0013 for why the split exists and why `body` is a property rather\n * than `cause`.\n *\n * @param message - The same one-line message the provider already throws\n * today (e.g. `meta: the Conversions API answered 400`). Not composed here.\n * @param details - The vendor's own answer: `provider` is the name it\n * registers under, `status` is the HTTP status, and `body` is the response\n * body verbatim, capped at 2048 characters with a `…[truncated]` suffix that\n * fits inside the cap.\n *\n * @example\n * ```ts\n * throw new VendorResponseError(`meta: the Conversions API answered ${status}`, {\n * provider: 'meta',\n * status,\n * body: await response.text().catch(() => ''),\n * });\n * ```\n */\nexport class VendorResponseError extends Error {\n readonly provider: string;\n readonly status: number;\n readonly body: string;\n\n constructor(message: string, details: { provider: string; status: number; body: string }) {\n super(message);\n this.name = 'VendorResponseError';\n this.provider = details.provider;\n this.status = details.status;\n this.body = capBody(details.body);\n Object.defineProperty(this, BRAND, { value: true });\n }\n}\n\n/**\n * Tests the brand `VendorResponseError` sets, not `instanceof`: two copies\n * of this package in one `node_modules` produce two constructors, and\n * `instanceof` would say `false` for an instance the other copy built. The\n * `Symbol.for` brand is the same symbol across both.\n *\n * @param value - Anything a `catch` block might hand back.\n * @returns Whether `value` carries the `VendorResponseError` brand.\n *\n * @example\n * ```ts\n * try {\n * await track.server('purchase', data, context);\n * } catch (error) {\n * if (isVendorResponseError(error)) {\n * reportWithFingerprint(error.provider, error.status, error.body);\n * }\n * }\n * ```\n */\nexport function isVendorResponseError(value: unknown): value is VendorResponseError {\n return typeof value === 'object' && value !== null && BRAND in value;\n}\n"],"mappings":";AAAA,MAAM,QAAQ,OAAO,IAAI,+BAA+B;;AAGxD,MAAM,WAAW;AACjB,MAAM,oBAAoB;AAE1B,SAAS,QAAQ,MAAsB;CACrC,IAAI,KAAK,UAAU,UAAU,OAAO;CACpC,OAAO,GAAG,KAAK,MAAM,GAAG,IAAmC,IAAI;AACjE;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,IAAa,sBAAb,cAAyC,MAAM;CAC7C;CACA;CACA;CAEA,YAAY,SAAiB,SAA6D;EACxF,MAAM,OAAO;EACb,KAAK,OAAO;EACZ,KAAK,WAAW,QAAQ;EACxB,KAAK,SAAS,QAAQ;EACtB,KAAK,OAAO,QAAQ,QAAQ,IAAI;EAChC,OAAO,eAAe,MAAM,OAAO,EAAE,OAAO,KAAK,CAAC;CACpD;AACF;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,sBAAsB,OAA8C;CAClF,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,SAAS;AACjE"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tracklane",
3
- "version": "0.6.0",
4
- "description": "One interface between your application and every tool that receives events, for the browser and server. GA4, Meta and PostHog today; LinkedIn and X next.",
3
+ "version": "0.7.0",
4
+ "description": "One interface between your application and every tool that receives events, for the browser and server. GA4, Meta, LinkedIn and PostHog; X next.",
5
5
  "keywords": [
6
6
  "analytics",
7
7
  "tracking",
@@ -52,7 +52,8 @@
52
52
  "node": ">=22.14.0"
53
53
  },
54
54
  "publishConfig": {
55
- "access": "public"
55
+ "access": "public",
56
+ "provenance": true
56
57
  },
57
58
  "devDependencies": {
58
59
  "@arethetypeswrong/cli": "0.18.5",
@@ -1 +0,0 @@
1
- {"version":3,"file":"browser.js","names":["push"],"sources":["../src/providers/ga4.browser.ts","../src/providers/meta.browser.ts","../src/providers/posthog.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","// 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\n/** The slice of `posthog-js` this adapter calls. */\ninterface PosthogSdk {\n capture: (event: string, properties?: EventData) => void;\n identify: (distinctId: string) => void;\n setPersonProperties: (properties: Record<string, unknown>) => void;\n opt_in_capturing: () => void;\n opt_out_capturing: () => void;\n}\n\ninterface PosthogWindow {\n posthog?: PosthogSdk;\n}\n\n/** Optional configuration for the PostHog browser provider. */\nexport interface PosthogBrowserConfig {\n /**\n * How this project spells each canonical event. Rarely needed: `capture()`\n * accepts any string, so the canonical vocabulary already is PostHog'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 posthogWindow(): PosthogWindow | undefined {\n return typeof window === 'undefined' ? undefined : (window as unknown as PosthogWindow);\n}\n\n/**\n * Returns the vendor SDK, or fails the way a hand-written call would.\n *\n * Calling `posthog.capture(...)` on a page without PostHog's snippet throws,\n * and this library replicates that rather than swallowing it: a silent no-op\n * is how a host ends up believing it is measuring something.\n */\nfunction sdk(): PosthogSdk {\n const instance = posthogWindow()?.posthog;\n\n if (!instance) {\n throw new Error(\n \"posthog: window.posthog is not defined. Add PostHog's snippet to the page before creating tracking.\",\n );\n }\n\n return instance;\n}\n\n/**\n * PostHog in the browser, through `posthog-js`.\n *\n * **This provider does not load PostHog's snippet.** It belongs on the page,\n * installed the way PostHog documents it: a snippet in the HTML, or a tag\n * manager. This library talks to the instance that is already there; it\n * never injects a third-party script into your page.\n *\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, posthog } from 'tracklane/browser';\n *\n * // PostHog's own snippet is already in the page.\n * const { track } = createTracking({ providers: [posthog()] });\n *\n * track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });\n * ```\n */\nexport function posthog(config: PosthogBrowserConfig = {}): BrowserProvider {\n const { events } = config;\n\n return {\n name: 'posthog',\n default: 'passthrough',\n ...(events ? { events } : {}),\n\n track(name: string, data: EventData, _options: TrackOptions): void {\n // capture() accepts any string as the event name and documents no\n // browser-to-server deduplication key, so dedupId maps nowhere — the\n // same gap GA4 has, for the same reason: there is no field to put it\n // in.\n sdk().capture(name, data);\n },\n\n identify(user: UserData, traits?: Record<string, unknown>): void {\n // distinct_id is PostHog's only identity slot. email, phone and names\n // have no separate slot the way Meta's user_data has one for each —\n // they map to nothing here, exactly the gap GA4 has for the same\n // fields.\n if (user.userId !== undefined) sdk().identify(user.userId);\n // setPersonProperties works against whichever identity is already\n // current, so it needs no id of its own — the same shape as GA4's\n // separate `set user_properties` push.\n if (traits) sdk().setPersonProperties(traits);\n },\n\n consent(_command: 'default' | 'update', state: ConsentState): void {\n // opt_in_capturing/opt_out_capturing is one binary switch, local SDK\n // state, not a per-signal API — nothing like Google's four commands.\n // analytics_storage is the one signal that actually describes what\n // this toggle governs (whether PostHog stores and sends analytics\n // events at all), so it is the one signal this collapses onto. The\n // other three have no honest PostHog equivalent; folding them in too\n // would risk an ad-only denial silently switching off analytics\n // capture the visitor never objected to, so they are left alone.\n const { analytics_storage } = state;\n\n if (analytics_storage === 'denied') {\n sdk().opt_out_capturing();\n } else if (analytics_storage === 'granted') {\n sdk().opt_in_capturing();\n }\n // Absent: no command fires. Whatever posture the page's own PostHog\n // snippet already set — opted in by default, or opted out through\n // `opt_out_capturing_by_default` — stands, rather than being guessed at.\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';\nexport { type PosthogBrowserConfig, posthog } from './providers/posthog.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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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"dispatch-BLBoCn-b.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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"errors-DLRGPWMj.js","names":[],"sources":["../src/errors.ts"],"sourcesContent":["const BRAND = Symbol.for('tracklane.VendorResponseError');\n\n/** The vendor's own answer to a refusal, capped so it cannot grow without bound. */\nconst BODY_CAP = 2048;\nconst TRUNCATION_SUFFIX = '…[truncated]';\n\nfunction capBody(body: string): string {\n if (body.length <= BODY_CAP) return body;\n return `${body.slice(0, BODY_CAP - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}`;\n}\n\n/**\n * Thrown by a server provider when the vendor's endpoint answers with a\n * non-2xx status. The message stays exactly what the provider composed,\n * free of the payload, per `TrackingError`'s guarantee, and the vendor's own\n * answer travels separately on `status` and `body`, for a host that asks for\n * it via `onError` rather than a host that logs by default.\n *\n * See ADR-0013 for why the split exists and why `body` is a property rather\n * than `cause`.\n *\n * @param message - The same one-line message the provider already throws\n * today (e.g. `meta: the Conversions API answered 400`). Not composed here.\n * @param details - The vendor's own answer: `provider` is the name it\n * registers under, `status` is the HTTP status, and `body` is the response\n * body verbatim, capped at 2048 characters with a `…[truncated]` suffix that\n * fits inside the cap.\n *\n * @example\n * ```ts\n * throw new VendorResponseError(`meta: the Conversions API answered ${status}`, {\n * provider: 'meta',\n * status,\n * body: await response.text().catch(() => ''),\n * });\n * ```\n */\nexport class VendorResponseError extends Error {\n readonly provider: string;\n readonly status: number;\n readonly body: string;\n\n constructor(message: string, details: { provider: string; status: number; body: string }) {\n super(message);\n this.name = 'VendorResponseError';\n this.provider = details.provider;\n this.status = details.status;\n this.body = capBody(details.body);\n Object.defineProperty(this, BRAND, { value: true });\n }\n}\n\n/**\n * Tests the brand `VendorResponseError` sets, not `instanceof`: two copies\n * of this package in one `node_modules` produce two constructors, and\n * `instanceof` would say `false` for an instance the other copy built. The\n * `Symbol.for` brand is the same symbol across both.\n *\n * @param value - Anything a `catch` block might hand back.\n * @returns Whether `value` carries the `VendorResponseError` brand.\n *\n * @example\n * ```ts\n * try {\n * await track.server('purchase', data, context);\n * } catch (error) {\n * if (isVendorResponseError(error)) {\n * reportWithFingerprint(error.provider, error.status, error.body);\n * }\n * }\n * ```\n */\nexport function isVendorResponseError(value: unknown): value is VendorResponseError {\n return typeof value === 'object' && value !== null && BRAND in value;\n}\n"],"mappings":";AAAA,MAAM,QAAQ,OAAO,IAAI,+BAA+B;;AAGxD,MAAM,WAAW;AACjB,MAAM,oBAAoB;AAE1B,SAAS,QAAQ,MAAsB;CACrC,IAAI,KAAK,UAAU,UAAU,OAAO;CACpC,OAAO,GAAG,KAAK,MAAM,GAAG,IAAmC,IAAI;AACjE;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,IAAa,sBAAb,cAAyC,MAAM;CAC7C;CACA;CACA;CAEA,YAAY,SAAiB,SAA6D;EACxF,MAAM,OAAO;EACb,KAAK,OAAO;EACZ,KAAK,WAAW,QAAQ;EACxB,KAAK,SAAS,QAAQ;EACtB,KAAK,OAAO,QAAQ,QAAQ,IAAI;EAChC,OAAO,eAAe,MAAM,OAAO,EAAE,OAAO,KAAK,CAAC;CACpD;AACF;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,sBAAsB,OAA8C;CAClF,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,SAAS;AACjE"}