tracklane 0.5.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/README.md +1 -1
- package/dist/{browser-CddkJdvX.d.ts → browser-34-CHcxK.d.ts} +163 -10
- package/dist/browser-DhzO5qPS.js +432 -0
- package/dist/browser-DhzO5qPS.js.map +1 -0
- package/dist/browser.d.ts +2 -2
- package/dist/browser.js +2 -397
- package/dist/conformance.d.ts +7 -1
- package/dist/conformance.js +67 -10
- package/dist/conformance.js.map +1 -1
- package/dist/{dispatch-BLBoCn-b.js → dispatch-B32jGhlh.js} +6 -3
- package/dist/dispatch-B32jGhlh.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -0
- package/dist/server-DnP7RtEn.js +647 -0
- package/dist/server-DnP7RtEn.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -509
- package/dist/src-DLRGPWMj.js +75 -0
- package/dist/src-DLRGPWMj.js.map +1 -0
- package/package.json +2 -2
- package/dist/browser.js.map +0 -1
- package/dist/dispatch-BLBoCn-b.js.map +0 -1
- package/dist/server.js.map +0 -1
package/dist/browser.js
CHANGED
|
@@ -1,397 +1,2 @@
|
|
|
1
|
-
import { i as
|
|
2
|
-
|
|
3
|
-
function gtagWindow() {
|
|
4
|
-
return typeof window === "undefined" ? void 0 : window;
|
|
5
|
-
}
|
|
6
|
-
/**
|
|
7
|
-
* Sends a command, or fails the way a hand-written call would.
|
|
8
|
-
*
|
|
9
|
-
* Writing `gtag(…)` on a page without Google's snippet throws, and this
|
|
10
|
-
* library replicates that rather than swallowing it: a silent no-op is how a
|
|
11
|
-
* host ends up believing it is measuring something.
|
|
12
|
-
*/
|
|
13
|
-
function push$1(...args) {
|
|
14
|
-
const gtag = gtagWindow()?.gtag;
|
|
15
|
-
if (!gtag) throw new Error("ga4: window.gtag is not defined. Add Google's tag to the page before creating tracking.");
|
|
16
|
-
gtag(...args);
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* Google Analytics 4 in the browser, through `gtag.js`.
|
|
20
|
-
*
|
|
21
|
-
* **This provider does not load Google's tag.** The tag belongs on the page,
|
|
22
|
-
* installed the way Google documents it: a snippet in the HTML, or a tag
|
|
23
|
-
* manager. This library talks to the tag that is already there; it never
|
|
24
|
-
* injects a third-party script into your page, and it never issues the
|
|
25
|
-
* property configuration that would emit a second page view.
|
|
26
|
-
*
|
|
27
|
-
* Which also means the initial consent declaration is not ours to make:
|
|
28
|
-
* Google requires it before the property is configured, and the snippet that
|
|
29
|
-
* configures the property is yours. Use {@link ConsentState} through
|
|
30
|
-
* `consent()` for changes after that.
|
|
31
|
-
*
|
|
32
|
-
* @param measurementId - The `G-` id of the data stream, matching the tag on
|
|
33
|
-
* the page.
|
|
34
|
-
* @param config - Rarely needed event name overrides.
|
|
35
|
-
* @returns A provider for `createTracking` from `tracklane/browser`.
|
|
36
|
-
*
|
|
37
|
-
* @example
|
|
38
|
-
* ```ts
|
|
39
|
-
* import { createTracking, ga4 } from 'tracklane/browser';
|
|
40
|
-
*
|
|
41
|
-
* // Google's own snippet is already in the page.
|
|
42
|
-
* const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });
|
|
43
|
-
*
|
|
44
|
-
* track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });
|
|
45
|
-
* ```
|
|
46
|
-
*/
|
|
47
|
-
function ga4(measurementId, config = {}) {
|
|
48
|
-
const { events } = config;
|
|
49
|
-
return {
|
|
50
|
-
name: "ga4",
|
|
51
|
-
default: "passthrough",
|
|
52
|
-
...events ? { events } : {},
|
|
53
|
-
track(name, data, _options) {
|
|
54
|
-
push$1("event", name, {
|
|
55
|
-
...data,
|
|
56
|
-
send_to: measurementId
|
|
57
|
-
});
|
|
58
|
-
},
|
|
59
|
-
identify(user, traits) {
|
|
60
|
-
if (user.userId !== void 0) push$1("set", { user_id: user.userId });
|
|
61
|
-
if (traits) push$1("set", "user_properties", traits);
|
|
62
|
-
},
|
|
63
|
-
consent(command, state) {
|
|
64
|
-
push$1("consent", command, state);
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
//#endregion
|
|
69
|
-
//#region src/providers/meta.browser.ts
|
|
70
|
-
/**
|
|
71
|
-
* The names Meta accepts through `fbq('track')`. Anything else is a custom
|
|
72
|
-
* event and goes through `trackCustom`, which is a different command rather
|
|
73
|
-
* than a different argument.
|
|
74
|
-
*
|
|
75
|
-
* Seventeen standard events plus `PageView`, which Meta documents separately
|
|
76
|
-
* from the seventeen and which the base snippet fires by itself.
|
|
77
|
-
*/
|
|
78
|
-
const STANDARD_EVENTS = [
|
|
79
|
-
"AddPaymentInfo",
|
|
80
|
-
"AddToCart",
|
|
81
|
-
"AddToWishlist",
|
|
82
|
-
"CompleteRegistration",
|
|
83
|
-
"Contact",
|
|
84
|
-
"CustomizeProduct",
|
|
85
|
-
"Donate",
|
|
86
|
-
"FindLocation",
|
|
87
|
-
"InitiateCheckout",
|
|
88
|
-
"Lead",
|
|
89
|
-
"PageView",
|
|
90
|
-
"Purchase",
|
|
91
|
-
"Schedule",
|
|
92
|
-
"Search",
|
|
93
|
-
"StartTrial",
|
|
94
|
-
"SubmitApplication",
|
|
95
|
-
"Subscribe",
|
|
96
|
-
"ViewContent"
|
|
97
|
-
];
|
|
98
|
-
/**
|
|
99
|
-
* How Meta spells the canonical vocabulary, which is GA4's.
|
|
100
|
-
*
|
|
101
|
-
* Only canonical names appear here. Meta has eight more standard events with
|
|
102
|
-
* no GA4 counterpart (`Subscribe`, `StartTrial`, `Schedule`, `Contact`,
|
|
103
|
-
* `Donate`, `FindLocation`, `CustomizeProduct`, `SubmitApplication`), and
|
|
104
|
-
* inventing canonical names for them would make this library the author of a
|
|
105
|
-
* vocabulary it deliberately borrows. A host that fires those maps its own
|
|
106
|
-
* event name to them through `events`.
|
|
107
|
-
*
|
|
108
|
-
* A canonical event with no standard counterpart is **not** dropped: it goes
|
|
109
|
-
* to `trackCustom` under its own name, because by hand that event would have
|
|
110
|
-
* been sent.
|
|
111
|
-
*/
|
|
112
|
-
const CANONICAL_EVENTS = {
|
|
113
|
-
page_view: "PageView",
|
|
114
|
-
view_item: "ViewContent",
|
|
115
|
-
search: "Search",
|
|
116
|
-
add_to_cart: "AddToCart",
|
|
117
|
-
add_to_wishlist: "AddToWishlist",
|
|
118
|
-
begin_checkout: "InitiateCheckout",
|
|
119
|
-
add_payment_info: "AddPaymentInfo",
|
|
120
|
-
purchase: "Purchase",
|
|
121
|
-
sign_up: "CompleteRegistration",
|
|
122
|
-
generate_lead: "Lead"
|
|
123
|
-
};
|
|
124
|
-
function fbqWindow() {
|
|
125
|
-
return typeof window === "undefined" ? void 0 : window;
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* Sends a command, or fails the way a hand-written call would.
|
|
129
|
-
*
|
|
130
|
-
* Writing `fbq(…)` on a page without Meta's snippet throws, and this library
|
|
131
|
-
* replicates that rather than swallowing it: a silent no-op is how a host
|
|
132
|
-
* ends up believing it is measuring something.
|
|
133
|
-
*/
|
|
134
|
-
function push(...args) {
|
|
135
|
-
const fbq = fbqWindow()?.fbq;
|
|
136
|
-
if (!fbq) throw new Error("meta: window.fbq is not defined. Add Meta's pixel to the page before creating tracking.");
|
|
137
|
-
fbq(...args);
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* GA4's `items` become Meta's `contents`, which is a closed shape: `id`,
|
|
141
|
-
* `quantity`, `item_price` and `delivery_category`, and nothing else.
|
|
142
|
-
*
|
|
143
|
-
* The rest of a GA4 item, `item_name` and `item_brand`, is dropped rather than
|
|
144
|
-
* forwarded, because a key Meta does not read inside `contents` looks mapped
|
|
145
|
-
* and arrives nowhere. That is the failure mode this project keeps finding.
|
|
146
|
-
*/
|
|
147
|
-
function contents(items) {
|
|
148
|
-
return items.map((item) => {
|
|
149
|
-
const content = {};
|
|
150
|
-
if (item.item_id !== void 0) content.id = item.item_id;
|
|
151
|
-
if (item.quantity !== void 0) content.quantity = item.quantity;
|
|
152
|
-
if (item.price !== void 0) content.item_price = item.price;
|
|
153
|
-
return content;
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* `value` and `currency` are spelled identically by both vendors, so they
|
|
158
|
-
* pass through untouched. Everything the host named itself passes through
|
|
159
|
-
* too: Meta keeps unknown keys as custom properties, which is exactly what a
|
|
160
|
-
* hand-written call would have produced.
|
|
161
|
-
*
|
|
162
|
-
* `transaction_id` stays under its own name here. Meta's pixel documents no
|
|
163
|
-
* order id among its object properties. `order_id` exists on the Conversions
|
|
164
|
-
* API and not on this surface, so translating it would be inventing a slot.
|
|
165
|
-
*/
|
|
166
|
-
function pixelParams(data) {
|
|
167
|
-
const { items, ...rest } = data;
|
|
168
|
-
const params = { ...rest };
|
|
169
|
-
if (items) params.contents = contents(items);
|
|
170
|
-
return params;
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* Meta's pixel has one consent switch where this library carries Google's
|
|
174
|
-
* four signals, so the four are collapsed into it.
|
|
175
|
-
*
|
|
176
|
-
* The collapse is the strictest declared answer: any `denied` produces
|
|
177
|
-
* `revoke`, and `grant` needs every declared signal to be `granted`. There is
|
|
178
|
-
* no field to partition, so this is the only reduction that cannot widen a
|
|
179
|
-
* denial, and widening is the one thing a projection must never do.
|
|
180
|
-
*
|
|
181
|
-
* A host that wants Meta to follow only its advertising category, and not its
|
|
182
|
-
* analytics one, decides that before `createTracking` by leaving this
|
|
183
|
-
* provider out, which is what `@tracklane/consent` exists to do.
|
|
184
|
-
*/
|
|
185
|
-
function collapse(state) {
|
|
186
|
-
const signals = [
|
|
187
|
-
state.ad_storage,
|
|
188
|
-
state.analytics_storage,
|
|
189
|
-
state.ad_user_data,
|
|
190
|
-
state.ad_personalization
|
|
191
|
-
].filter((signal) => signal !== void 0);
|
|
192
|
-
if (signals.length === 0) return void 0;
|
|
193
|
-
return signals.every((signal) => signal === "granted") ? "grant" : "revoke";
|
|
194
|
-
}
|
|
195
|
-
/**
|
|
196
|
-
* The Meta pixel in the browser, through `fbq`.
|
|
197
|
-
*
|
|
198
|
-
* **This provider does not load Meta's pixel.** The base code belongs on the
|
|
199
|
-
* page, installed the way
|
|
200
|
-
* [Meta documents it](https://developers.facebook.com/docs/meta-pixel/get-started):
|
|
201
|
-
* a snippet in the HTML, or a tag manager. This library talks to the pixel
|
|
202
|
-
* that is already there; it never injects a third-party script and never
|
|
203
|
-
* issues `fbq('init')`.
|
|
204
|
-
*
|
|
205
|
-
* Three consequences follow from that, and all three are Meta's rules rather
|
|
206
|
-
* than ours:
|
|
207
|
-
*
|
|
208
|
-
* - **There is no `identify` here.** Meta accepts advanced matching only in
|
|
209
|
-
* the base code ("be sure to place advanced matching parameters in the
|
|
210
|
-
* pixel base code or the values will not be treated as manual advanced
|
|
211
|
-
* matching values"), and the base code is yours. Put `em`, `ph`, `fn`, `ln`
|
|
212
|
-
* in your own `fbq('init')`, where the pixel normalises and hashes them for
|
|
213
|
-
* you. On the server, where there is no snippet, the same identity travels
|
|
214
|
-
* in `context.user` and is hashed by the adapter.
|
|
215
|
-
* - **The initial consent declaration is not ours.** Meta wants
|
|
216
|
-
* `fbq('consent', 'revoke')` before `init`, and by the time you hold what
|
|
217
|
-
* `createTracking` returns that moment has passed. Ours is every
|
|
218
|
-
* declaration after it, through `consent()`.
|
|
219
|
-
* - **Limited Data Use is not ours either.** `fbq('dataProcessingOptions', …)`
|
|
220
|
-
* is documented as running before `init`. It belongs in your snippet. The
|
|
221
|
-
* server half, which has no snippet, carries it on its factory.
|
|
222
|
-
*
|
|
223
|
-
* **There is no pixel id here**, and that is deliberate rather than an
|
|
224
|
-
* omission. `fbq('track')` reaches every pixel the page initialised, so an id
|
|
225
|
-
* passed in would address nothing and the parameter would promise something
|
|
226
|
-
* this adapter cannot deliver. Which pixels exist is declared by your snippet,
|
|
227
|
-
* in `fbq('init')`.
|
|
228
|
-
*
|
|
229
|
-
* @param config - Event name overrides, merged over the built-in map.
|
|
230
|
-
* @returns A provider for `createTracking` from `tracklane/browser`.
|
|
231
|
-
*
|
|
232
|
-
* @example
|
|
233
|
-
* ```ts
|
|
234
|
-
* import { createTracking, meta } from 'tracklane/browser';
|
|
235
|
-
*
|
|
236
|
-
* // Meta's own base code is already in the page, and it named the pixel.
|
|
237
|
-
* const { track } = createTracking({ providers: [meta()] });
|
|
238
|
-
*
|
|
239
|
-
* // fbq('track', 'Purchase', { value: 49.9, currency: 'BRL' }, { eventID: 'T-1' })
|
|
240
|
-
* track('purchase', { value: 49.9, currency: 'BRL' }, { dedupId: 'T-1' });
|
|
241
|
-
* ```
|
|
242
|
-
*/
|
|
243
|
-
function meta(config = {}) {
|
|
244
|
-
return {
|
|
245
|
-
name: "meta",
|
|
246
|
-
default: "passthrough",
|
|
247
|
-
events: {
|
|
248
|
-
...CANONICAL_EVENTS,
|
|
249
|
-
...config.events
|
|
250
|
-
},
|
|
251
|
-
track(name, data, options) {
|
|
252
|
-
const args = [
|
|
253
|
-
STANDARD_EVENTS.includes(name) ? "track" : "trackCustom",
|
|
254
|
-
name,
|
|
255
|
-
pixelParams(data)
|
|
256
|
-
];
|
|
257
|
-
if (options.dedupId !== void 0) args.push({ eventID: options.dedupId });
|
|
258
|
-
push(...args);
|
|
259
|
-
},
|
|
260
|
-
consent(_command, state) {
|
|
261
|
-
const answer = collapse(state);
|
|
262
|
-
if (answer) push("consent", answer);
|
|
263
|
-
}
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
//#endregion
|
|
267
|
-
//#region src/providers/posthog.browser.ts
|
|
268
|
-
function posthogWindow() {
|
|
269
|
-
return typeof window === "undefined" ? void 0 : window;
|
|
270
|
-
}
|
|
271
|
-
/**
|
|
272
|
-
* Returns the vendor SDK, or fails the way a hand-written call would.
|
|
273
|
-
*
|
|
274
|
-
* Calling `posthog.capture(...)` on a page without PostHog's snippet throws,
|
|
275
|
-
* and this library replicates that rather than swallowing it: a silent no-op
|
|
276
|
-
* is how a host ends up believing it is measuring something.
|
|
277
|
-
*/
|
|
278
|
-
function sdk() {
|
|
279
|
-
const instance = posthogWindow()?.posthog;
|
|
280
|
-
if (!instance) throw new Error("posthog: window.posthog is not defined. Add PostHog's snippet to the page before creating tracking.");
|
|
281
|
-
return instance;
|
|
282
|
-
}
|
|
283
|
-
/**
|
|
284
|
-
* PostHog in the browser, through `posthog-js`.
|
|
285
|
-
*
|
|
286
|
-
* **This provider does not load PostHog's snippet.** It belongs on the page,
|
|
287
|
-
* installed the way PostHog documents it: a snippet in the HTML, or a tag
|
|
288
|
-
* manager. This library talks to the instance that is already there; it
|
|
289
|
-
* never injects a third-party script into your page.
|
|
290
|
-
*
|
|
291
|
-
* @param config - Rarely needed event name overrides.
|
|
292
|
-
* @returns A provider for `createTracking` from `tracklane/browser`.
|
|
293
|
-
*
|
|
294
|
-
* @example
|
|
295
|
-
* ```ts
|
|
296
|
-
* import { createTracking, posthog } from 'tracklane/browser';
|
|
297
|
-
*
|
|
298
|
-
* // PostHog's own snippet is already in the page.
|
|
299
|
-
* const { track } = createTracking({ providers: [posthog()] });
|
|
300
|
-
*
|
|
301
|
-
* track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });
|
|
302
|
-
* ```
|
|
303
|
-
*/
|
|
304
|
-
function posthog(config = {}) {
|
|
305
|
-
const { events } = config;
|
|
306
|
-
return {
|
|
307
|
-
name: "posthog",
|
|
308
|
-
default: "passthrough",
|
|
309
|
-
...events ? { events } : {},
|
|
310
|
-
track(name, data, _options) {
|
|
311
|
-
sdk().capture(name, data);
|
|
312
|
-
},
|
|
313
|
-
identify(user, traits) {
|
|
314
|
-
if (user.userId !== void 0) sdk().identify(user.userId);
|
|
315
|
-
if (traits) sdk().setPersonProperties(traits);
|
|
316
|
-
},
|
|
317
|
-
consent(_command, state) {
|
|
318
|
-
const { analytics_storage } = state;
|
|
319
|
-
if (analytics_storage === "denied") sdk().opt_out_capturing();
|
|
320
|
-
else if (analytics_storage === "granted") sdk().opt_in_capturing();
|
|
321
|
-
}
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
//#endregion
|
|
325
|
-
//#region src/browser.ts
|
|
326
|
-
/**
|
|
327
|
-
* Creates the browser tracking surface.
|
|
328
|
-
*
|
|
329
|
-
* Providers install eagerly, which is what pasting a vendor snippet does and
|
|
330
|
-
* what lets each vendor record its own initial page view. Every vendor
|
|
331
|
-
* shipped here queues its own commands until its script lands, so nothing is
|
|
332
|
-
* lost to loading and this library needs no queue of its own.
|
|
333
|
-
*
|
|
334
|
-
* @param options - The providers to send to, and an optional error channel.
|
|
335
|
-
* @returns The `track`, `identify` and `consent` calls.
|
|
336
|
-
*
|
|
337
|
-
* @example
|
|
338
|
-
* ```ts
|
|
339
|
-
* import { createTracking, ga4 } from 'tracklane/browser';
|
|
340
|
-
*
|
|
341
|
-
* const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });
|
|
342
|
-
* track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });
|
|
343
|
-
* ```
|
|
344
|
-
*/
|
|
345
|
-
function createTracking(options) {
|
|
346
|
-
const { providers } = options;
|
|
347
|
-
assertUniqueProviders(providers);
|
|
348
|
-
const report = makeReporter(options.onError);
|
|
349
|
-
const fail = (provider, event, cause) => {
|
|
350
|
-
report({
|
|
351
|
-
provider,
|
|
352
|
-
event,
|
|
353
|
-
severity: "error",
|
|
354
|
-
message: messageOf(cause),
|
|
355
|
-
cause
|
|
356
|
-
});
|
|
357
|
-
};
|
|
358
|
-
for (const provider of providers) try {
|
|
359
|
-
const installing = provider.install?.();
|
|
360
|
-
Promise.resolve(installing).catch((cause) => {
|
|
361
|
-
fail(provider.name, "install", cause);
|
|
362
|
-
});
|
|
363
|
-
} catch (cause) {
|
|
364
|
-
fail(provider.name, "install", cause);
|
|
365
|
-
}
|
|
366
|
-
return {
|
|
367
|
-
track(name, data = {}, trackOptions = {}) {
|
|
368
|
-
for (const provider of providers) {
|
|
369
|
-
const target = resolveEvent(provider, name);
|
|
370
|
-
if (target === void 0) continue;
|
|
371
|
-
try {
|
|
372
|
-
provider.track(target, data, trackOptions);
|
|
373
|
-
} catch (cause) {
|
|
374
|
-
fail(provider.name, name, cause);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
},
|
|
378
|
-
identify(user, traits) {
|
|
379
|
-
for (const provider of providers) try {
|
|
380
|
-
provider.identify?.(user, traits);
|
|
381
|
-
} catch (cause) {
|
|
382
|
-
fail(provider.name, "identify", cause);
|
|
383
|
-
}
|
|
384
|
-
},
|
|
385
|
-
consent(command, state) {
|
|
386
|
-
for (const provider of providers) try {
|
|
387
|
-
provider.consent?.(command, state);
|
|
388
|
-
} catch (cause) {
|
|
389
|
-
fail(provider.name, "consent", cause);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
//#endregion
|
|
395
|
-
export { createTracking, ga4, meta, posthog };
|
|
396
|
-
|
|
397
|
-
//# sourceMappingURL=browser.js.map
|
|
1
|
+
import { a as ga4, i as linkedin, n as posthog, r as meta, t as createTracking } from "./browser-DhzO5qPS.js";
|
|
2
|
+
export { createTracking, ga4, linkedin, meta, posthog };
|
package/dist/conformance.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { g as ServerProvider, h as ResolvedContext, t as BrowserProvider } from "./browser-34-CHcxK.js";
|
|
2
2
|
//#region src/conformance.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* Runs the shared conformance suite against a browser provider.
|
|
@@ -8,6 +8,7 @@ import { m as ServerProvider, p as ResolvedContext, t as BrowserProvider } from
|
|
|
8
8
|
* @param captured - Everything the provider handed the vendor since the last
|
|
9
9
|
* reset, in order.
|
|
10
10
|
* @param reset - Clears what `captured` returns.
|
|
11
|
+
* @param target - An already-resolved vendor target. Defaults to `purchase`.
|
|
11
12
|
*
|
|
12
13
|
* @example
|
|
13
14
|
* ```ts
|
|
@@ -24,6 +25,8 @@ declare function conformsAsBrowserProvider(options: {
|
|
|
24
25
|
create: () => BrowserProvider;
|
|
25
26
|
captured: () => unknown[];
|
|
26
27
|
reset: () => void;
|
|
28
|
+
/** Already-resolved vendor target; conversion-ID providers use their configured ID. */
|
|
29
|
+
target?: string;
|
|
27
30
|
}): void;
|
|
28
31
|
/**
|
|
29
32
|
* Runs the shared conformance suite against a server provider.
|
|
@@ -35,6 +38,7 @@ declare function conformsAsBrowserProvider(options: {
|
|
|
35
38
|
* @param fail - Makes the next send fail at the transport, so the suite can
|
|
36
39
|
* check that the provider is loud about it.
|
|
37
40
|
* @param context - A resolved context with a fixed timestamp.
|
|
41
|
+
* @param target - An already-resolved vendor target. Defaults to `purchase`.
|
|
38
42
|
*
|
|
39
43
|
* @example
|
|
40
44
|
* ```ts
|
|
@@ -55,6 +59,8 @@ declare function conformsAsServerProvider(options: {
|
|
|
55
59
|
reset: () => void;
|
|
56
60
|
fail: () => void;
|
|
57
61
|
context: () => ResolvedContext;
|
|
62
|
+
/** Already-resolved vendor target; conversion-ID providers use their configured ID. */
|
|
63
|
+
target?: string;
|
|
58
64
|
}): void;
|
|
59
65
|
//#endregion
|
|
60
66
|
export { conformsAsBrowserProvider, conformsAsServerProvider };
|
package/dist/conformance.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { n as isVendorResponseError } from "./src-DLRGPWMj.js";
|
|
2
|
+
import { t as createTracking } from "./browser-DhzO5qPS.js";
|
|
3
|
+
import { t as createTracking$1 } from "./server-DnP7RtEn.js";
|
|
1
4
|
import { expect, it } from "vitest";
|
|
2
5
|
//#region src/conformance.ts
|
|
3
6
|
/**
|
|
@@ -50,6 +53,7 @@ function assertIdentity(provider) {
|
|
|
50
53
|
* @param captured - Everything the provider handed the vendor since the last
|
|
51
54
|
* reset, in order.
|
|
52
55
|
* @param reset - Clears what `captured` returns.
|
|
56
|
+
* @param target - An already-resolved vendor target. Defaults to `purchase`.
|
|
53
57
|
*
|
|
54
58
|
* @example
|
|
55
59
|
* ```ts
|
|
@@ -64,22 +68,43 @@ function assertIdentity(provider) {
|
|
|
64
68
|
*/
|
|
65
69
|
function conformsAsBrowserProvider(options) {
|
|
66
70
|
const { create, captured, reset } = options;
|
|
71
|
+
const target = options.target ?? "purchase";
|
|
67
72
|
assertIdentity(create());
|
|
73
|
+
it("reports absent mappings on ignore providers and keeps explicit exclusions silent", () => {
|
|
74
|
+
const { install: _install, ...provider } = create();
|
|
75
|
+
if (provider.default !== "ignore") return;
|
|
76
|
+
reset();
|
|
77
|
+
const errors = [];
|
|
78
|
+
const tracking = createTracking({
|
|
79
|
+
providers: [{
|
|
80
|
+
...provider,
|
|
81
|
+
events: { explicit: null }
|
|
82
|
+
}],
|
|
83
|
+
onError: (error) => errors.push(error)
|
|
84
|
+
});
|
|
85
|
+
tracking.track("missing");
|
|
86
|
+
tracking.track("explicit");
|
|
87
|
+
expect(errors).toMatchObject([{
|
|
88
|
+
severity: "warning",
|
|
89
|
+
event: "missing"
|
|
90
|
+
}]);
|
|
91
|
+
expect(captured()).toEqual([]);
|
|
92
|
+
});
|
|
68
93
|
it("sends the same thing twice for the same event", () => {
|
|
69
94
|
reset();
|
|
70
|
-
create().track(
|
|
95
|
+
create().track(target, STABLE_DATA, {});
|
|
71
96
|
const first = JSON.stringify(captured());
|
|
72
97
|
reset();
|
|
73
|
-
create().track(
|
|
98
|
+
create().track(target, STABLE_DATA, {});
|
|
74
99
|
const second = JSON.stringify(captured());
|
|
75
100
|
expect(second).toBe(first);
|
|
76
101
|
});
|
|
77
102
|
it("forwards the deduplication id only when it was given one", () => {
|
|
78
103
|
reset();
|
|
79
|
-
create().track(
|
|
104
|
+
create().track(target, STABLE_DATA, {});
|
|
80
105
|
const without = JSON.stringify(captured());
|
|
81
106
|
reset();
|
|
82
|
-
create().track(
|
|
107
|
+
create().track(target, STABLE_DATA, { dedupId: "DEDUP-XYZ" });
|
|
83
108
|
const with_ = JSON.stringify(captured());
|
|
84
109
|
if (with_ !== without) expect(with_).toContain("DEDUP-XYZ");
|
|
85
110
|
expect(without).not.toContain("DEDUP-XYZ");
|
|
@@ -102,6 +127,7 @@ function conformsAsBrowserProvider(options) {
|
|
|
102
127
|
* @param fail - Makes the next send fail at the transport, so the suite can
|
|
103
128
|
* check that the provider is loud about it.
|
|
104
129
|
* @param context - A resolved context with a fixed timestamp.
|
|
130
|
+
* @param target - An already-resolved vendor target. Defaults to `purchase`.
|
|
105
131
|
*
|
|
106
132
|
* @example
|
|
107
133
|
* ```ts
|
|
@@ -118,22 +144,43 @@ function conformsAsBrowserProvider(options) {
|
|
|
118
144
|
*/
|
|
119
145
|
function conformsAsServerProvider(options) {
|
|
120
146
|
const { create, captured, reset, fail, context } = options;
|
|
147
|
+
const target = options.target ?? "purchase";
|
|
121
148
|
const noop = () => {};
|
|
122
149
|
assertIdentity(create());
|
|
150
|
+
it("reports absent mappings on ignore providers and keeps explicit exclusions silent", async () => {
|
|
151
|
+
const provider = create();
|
|
152
|
+
if (provider.default !== "ignore") return;
|
|
153
|
+
reset();
|
|
154
|
+
const errors = [];
|
|
155
|
+
const tracking = createTracking$1({
|
|
156
|
+
providers: [{
|
|
157
|
+
...provider,
|
|
158
|
+
events: { explicit: null }
|
|
159
|
+
}],
|
|
160
|
+
onError: (error) => errors.push(error)
|
|
161
|
+
});
|
|
162
|
+
await tracking.track("missing", {}, context());
|
|
163
|
+
await tracking.track("explicit", {}, context());
|
|
164
|
+
expect(errors).toMatchObject([{
|
|
165
|
+
severity: "warning",
|
|
166
|
+
event: "missing"
|
|
167
|
+
}]);
|
|
168
|
+
expect(captured()).toEqual([]);
|
|
169
|
+
});
|
|
123
170
|
it("sends the same thing twice for the same event", async () => {
|
|
124
171
|
reset();
|
|
125
|
-
await create().track(
|
|
172
|
+
await create().track(target, STABLE_DATA, context(), noop);
|
|
126
173
|
const first = JSON.stringify(captured());
|
|
127
174
|
reset();
|
|
128
|
-
await create().track(
|
|
175
|
+
await create().track(target, STABLE_DATA, context(), noop);
|
|
129
176
|
expect(JSON.stringify(captured())).toBe(first);
|
|
130
177
|
});
|
|
131
178
|
it("forwards the deduplication id only when it was given one", async () => {
|
|
132
179
|
reset();
|
|
133
|
-
await create().track(
|
|
180
|
+
await create().track(target, STABLE_DATA, context(), noop);
|
|
134
181
|
const without = JSON.stringify(captured());
|
|
135
182
|
reset();
|
|
136
|
-
await create().track(
|
|
183
|
+
await create().track(target, STABLE_DATA, {
|
|
137
184
|
...context(),
|
|
138
185
|
dedupId: "DEDUP-XYZ"
|
|
139
186
|
}, noop);
|
|
@@ -141,10 +188,20 @@ function conformsAsServerProvider(options) {
|
|
|
141
188
|
if (with_ !== without) expect(with_).toContain("DEDUP-XYZ");
|
|
142
189
|
expect(without).not.toContain("DEDUP-XYZ");
|
|
143
190
|
});
|
|
144
|
-
it("throws when the vendor did not accept the send", async () => {
|
|
191
|
+
it("throws a VendorResponseError when the vendor did not accept the send", async () => {
|
|
145
192
|
reset();
|
|
146
193
|
fail();
|
|
147
|
-
|
|
194
|
+
let caught;
|
|
195
|
+
try {
|
|
196
|
+
await create().track(target, STABLE_DATA, context(), noop);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
caught = error;
|
|
199
|
+
}
|
|
200
|
+
expect(isVendorResponseError(caught)).toBe(true);
|
|
201
|
+
if (isVendorResponseError(caught)) {
|
|
202
|
+
expect(typeof caught.status).toBe("number");
|
|
203
|
+
expect(typeof caught.body).toBe("string");
|
|
204
|
+
}
|
|
148
205
|
});
|
|
149
206
|
}
|
|
150
207
|
//#endregion
|
package/dist/conformance.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conformance.js","names":[],"sources":["../src/conformance.ts"],"sourcesContent":["import { expect, it } from 'vitest';\nimport type { BrowserProvider } from './browser.js';\nimport type { ResolvedContext, ServerProvider } from './server.js';\nimport type { EventData, ProviderDefault } from './vocabulary.js';\n\n/**\n * The behaviour every provider owes, which the types cannot express.\n *\n * ADR-0004 requires this suite because a provider can satisfy\n * {@link BrowserProvider} or {@link ServerProvider} perfectly and still break\n * a rule the whole library rests on. The interface cannot say \"never invent an\n * identifier\" or \"never expand a consent signal\"; a test can.\n *\n * These are invariants, not vendor behaviour. What GA4 sends and how it spells\n * it belongs in the GA4 tests. What follows must hold for a provider written\n * by anyone, for a vendor nobody here has heard of.\n *\n * **Published as `tracklane/conformance`**, because the claim that a provider\n * written elsewhere uses the same contract as the ones shipped here is only\n * true if it can run the same checks. `vitest` is an optional peer: import this\n * from a test file and call one of the two functions below inside a `describe`.\n *\n * **A wrapper owes the same invariants as a provider.** Wrapping a shipped\n * provider and delegating (see the provider documentation) produces an object\n * that the dispatcher cannot tell apart from any other, so run it through here\n * too. On the server half that catches the expensive mistake: a wrapper whose\n * `track` does not `await` the provider it delegates to resolves before the\n * request settles, which a serverless runtime turns into a conversion that\n * never left. {@link conformsAsServerProvider} fails on it, because a vendor\n * refusal can no longer reach the caller.\n */\n\nconst DEFAULTS: ProviderDefault[] = ['passthrough', 'ignore'];\n\n/** A payload with nothing in it that could excuse a difference between runs. */\nconst STABLE_DATA: EventData = { transaction_id: 'T-1', value: 49.9, currency: 'BRL' };\n\nfunction assertIdentity(provider: { name: string; default: ProviderDefault }): void {\n it('names itself, once and stably', () => {\n // The name is what a duplicate registration is detected by, and what an\n // error report is attributed to.\n expect(provider.name).toBeTruthy();\n expect(provider.name.trim()).toBe(provider.name);\n });\n\n it('declares what happens to an event it has no binding for', () => {\n expect(DEFAULTS).toContain(provider.default);\n });\n}\n\n/**\n * Runs the shared conformance suite against a browser provider.\n *\n * @param create - Builds the provider under test. Called per assertion, so a\n * provider that accumulates state between runs fails rather than hides.\n * @param captured - Everything the provider handed the vendor since the last\n * reset, in order.\n * @param reset - Clears what `captured` returns.\n *\n * @example\n * ```ts\n * describe('ga4 browser conformance', () => {\n * conformsAsBrowserProvider({\n * create: () => ga4('G-ABC'),\n * captured: () => calls,\n * reset: () => { calls = []; },\n * });\n * });\n * ```\n */\nexport function conformsAsBrowserProvider(options: {\n create: () => BrowserProvider;\n captured: () => unknown[];\n reset: () => void;\n}): void {\n const { create, captured, reset } = options;\n\n assertIdentity(create());\n\n it('sends the same thing twice for the same event', () => {\n // The library never invents an identifier, and this is how that rule is\n // caught rather than trusted: a generated id, a random value or a clock\n // read makes two identical calls differ.\n reset();\n create().track('purchase', STABLE_DATA, {});\n const first = JSON.stringify(captured());\n\n reset();\n create().track('purchase', STABLE_DATA, {});\n const second = JSON.stringify(captured());\n\n expect(second).toBe(first);\n });\n\n it('forwards the deduplication id only when it was given one', () => {\n reset();\n create().track('purchase', STABLE_DATA, {});\n const without = JSON.stringify(captured());\n\n reset();\n create().track('purchase', STABLE_DATA, { dedupId: 'DEDUP-XYZ' });\n const with_ = JSON.stringify(captured());\n\n // Either the vendor has the concept, and the value appears where it did\n // not before, or it has none and nothing changes. Inventing one to fill\n // the slot is the failure this catches.\n if (with_ !== without) expect(with_).toContain('DEDUP-XYZ');\n expect(without).not.toContain('DEDUP-XYZ');\n });\n\n it('never expands a consent signal it was not given', () => {\n const provider = create();\n if (!provider.consent) return;\n\n reset();\n provider.consent('update', { analytics_storage: 'denied' });\n\n // One denial in must never become a grant out. Collapsing Google's\n // vocabulary for a coarser vendor is translation; widening it invents a\n // permission the visitor never gave.\n expect(JSON.stringify(captured())).not.toContain('granted');\n });\n}\n\n/**\n * Runs the shared conformance suite against a server provider.\n *\n * @param create - Builds the provider under test, per assertion.\n * @param captured - Everything the provider sent since the last reset.\n * @param reset - Clears what `captured` returns, and restores a successful\n * transport.\n * @param fail - Makes the next send fail at the transport, so the suite can\n * check that the provider is loud about it.\n * @param context - A resolved context with a fixed timestamp.\n *\n * @example\n * ```ts\n * describe('ga4 server conformance', () => {\n * conformsAsServerProvider({\n * create: () => ga4(credentials),\n * captured: () => fetchMock.mock.calls,\n * reset: () => { fetchMock.mockClear(); },\n * fail: () => { fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })); },\n * context,\n * });\n * });\n * ```\n */\nexport function conformsAsServerProvider(options: {\n create: () => ServerProvider;\n captured: () => unknown[];\n reset: () => void;\n fail: () => void;\n context: () => ResolvedContext;\n}): void {\n const { create, captured, reset, fail, context } = options;\n const noop = (): void => {};\n\n assertIdentity(create());\n\n it('sends the same thing twice for the same event', async () => {\n // The context carries a fixed timestamp, so any difference between these\n // two is something the provider produced on its own.\n reset();\n await create().track('purchase', STABLE_DATA, context(), noop);\n const first = JSON.stringify(captured());\n\n reset();\n await create().track('purchase', STABLE_DATA, context(), noop);\n\n expect(JSON.stringify(captured())).toBe(first);\n });\n\n it('forwards the deduplication id only when it was given one', async () => {\n reset();\n await create().track('purchase', STABLE_DATA, context(), noop);\n const without = JSON.stringify(captured());\n\n reset();\n await create().track('purchase', STABLE_DATA, { ...context(), dedupId: 'DEDUP-XYZ' }, noop);\n const with_ = JSON.stringify(captured());\n\n if (with_ !== without) expect(with_).toContain('DEDUP-XYZ');\n expect(without).not.toContain('DEDUP-XYZ');\n });\n\n it('throws when the vendor did not accept the send', async () => {\n // Adapters stay honest and loud. Swallowing a non-2xx is how a host ends\n // up believing it is measuring something, and the dispatcher is already\n // there to isolate the throw and report it.\n reset();\n fail();\n\n await expect(create().track('purchase', STABLE_DATA, context(), noop)).rejects.toThrow();\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"conformance.js","names":["createBrowserTracking","createServerTracking"],"sources":["../src/conformance.ts"],"sourcesContent":["import { expect, it } from 'vitest';\nimport { type BrowserProvider, createTracking as createBrowserTracking } from './browser.js';\nimport { isVendorResponseError } from './errors.js';\nimport {\n createTracking as createServerTracking,\n type ResolvedContext,\n type ServerProvider,\n} from './server.js';\nimport type { EventData, ProviderDefault } from './vocabulary.js';\n\n/**\n * The behaviour every provider owes, which the types cannot express.\n *\n * ADR-0004 requires this suite because a provider can satisfy\n * {@link BrowserProvider} or {@link ServerProvider} perfectly and still break\n * a rule the whole library rests on. The interface cannot say \"never invent an\n * identifier\" or \"never expand a consent signal\"; a test can.\n *\n * These are invariants, not vendor behaviour. What GA4 sends and how it spells\n * it belongs in the GA4 tests. What follows must hold for a provider written\n * by anyone, for a vendor nobody here has heard of.\n *\n * **Published as `tracklane/conformance`**, because the claim that a provider\n * written elsewhere uses the same contract as the ones shipped here is only\n * true if it can run the same checks. `vitest` is an optional peer: import this\n * from a test file and call one of the two functions below inside a `describe`.\n *\n * **A wrapper owes the same invariants as a provider.** Wrapping a shipped\n * provider and delegating (see the provider documentation) produces an object\n * that the dispatcher cannot tell apart from any other, so run it through here\n * too. On the server half that catches the expensive mistake: a wrapper whose\n * `track` does not `await` the provider it delegates to resolves before the\n * request settles, which a serverless runtime turns into a conversion that\n * never left. {@link conformsAsServerProvider} fails on it, because a vendor\n * refusal can no longer reach the caller.\n */\n\nconst DEFAULTS: ProviderDefault[] = ['passthrough', 'ignore'];\n\n/** A payload with nothing in it that could excuse a difference between runs. */\nconst STABLE_DATA: EventData = { transaction_id: 'T-1', value: 49.9, currency: 'BRL' };\n\nfunction assertIdentity(provider: { name: string; default: ProviderDefault }): void {\n it('names itself, once and stably', () => {\n // The name is what a duplicate registration is detected by, and what an\n // error report is attributed to.\n expect(provider.name).toBeTruthy();\n expect(provider.name.trim()).toBe(provider.name);\n });\n\n it('declares what happens to an event it has no binding for', () => {\n expect(DEFAULTS).toContain(provider.default);\n });\n}\n\n/**\n * Runs the shared conformance suite against a browser provider.\n *\n * @param create - Builds the provider under test. Called per assertion, so a\n * provider that accumulates state between runs fails rather than hides.\n * @param captured - Everything the provider handed the vendor since the last\n * reset, in order.\n * @param reset - Clears what `captured` returns.\n * @param target - An already-resolved vendor target. Defaults to `purchase`.\n *\n * @example\n * ```ts\n * describe('ga4 browser conformance', () => {\n * conformsAsBrowserProvider({\n * create: () => ga4('G-ABC'),\n * captured: () => calls,\n * reset: () => { calls = []; },\n * });\n * });\n * ```\n */\nexport function conformsAsBrowserProvider(options: {\n create: () => BrowserProvider;\n captured: () => unknown[];\n reset: () => void;\n /** Already-resolved vendor target; conversion-ID providers use their configured ID. */\n target?: string;\n}): void {\n const { create, captured, reset } = options;\n const target = options.target ?? 'purchase';\n\n assertIdentity(create());\n\n it('reports absent mappings on ignore providers and keeps explicit exclusions silent', () => {\n // Mapping assertions do not exercise the host's installation hook.\n const { install: _install, ...provider } = create();\n if (provider.default !== 'ignore') return;\n reset();\n const errors: { severity: string; event: string }[] = [];\n const tracking = createBrowserTracking({\n providers: [{ ...provider, events: { explicit: null } }],\n onError: (error) => errors.push(error),\n });\n tracking.track('missing');\n tracking.track('explicit');\n expect(errors).toMatchObject([{ severity: 'warning', event: 'missing' }]);\n expect(captured()).toEqual([]);\n });\n\n it('sends the same thing twice for the same event', () => {\n // The library never invents an identifier, and this is how that rule is\n // caught rather than trusted: a generated id, a random value or a clock\n // read makes two identical calls differ.\n reset();\n create().track(target, STABLE_DATA, {});\n const first = JSON.stringify(captured());\n\n reset();\n create().track(target, STABLE_DATA, {});\n const second = JSON.stringify(captured());\n\n expect(second).toBe(first);\n });\n\n it('forwards the deduplication id only when it was given one', () => {\n reset();\n create().track(target, STABLE_DATA, {});\n const without = JSON.stringify(captured());\n\n reset();\n create().track(target, STABLE_DATA, { dedupId: 'DEDUP-XYZ' });\n const with_ = JSON.stringify(captured());\n\n // Either the vendor has the concept, and the value appears where it did\n // not before, or it has none and nothing changes. Inventing one to fill\n // the slot is the failure this catches.\n if (with_ !== without) expect(with_).toContain('DEDUP-XYZ');\n expect(without).not.toContain('DEDUP-XYZ');\n });\n\n it('never expands a consent signal it was not given', () => {\n const provider = create();\n if (!provider.consent) return;\n\n reset();\n provider.consent('update', { analytics_storage: 'denied' });\n\n // One denial in must never become a grant out. Collapsing Google's\n // vocabulary for a coarser vendor is translation; widening it invents a\n // permission the visitor never gave.\n expect(JSON.stringify(captured())).not.toContain('granted');\n });\n}\n\n/**\n * Runs the shared conformance suite against a server provider.\n *\n * @param create - Builds the provider under test, per assertion.\n * @param captured - Everything the provider sent since the last reset.\n * @param reset - Clears what `captured` returns, and restores a successful\n * transport.\n * @param fail - Makes the next send fail at the transport, so the suite can\n * check that the provider is loud about it.\n * @param context - A resolved context with a fixed timestamp.\n * @param target - An already-resolved vendor target. Defaults to `purchase`.\n *\n * @example\n * ```ts\n * describe('ga4 server conformance', () => {\n * conformsAsServerProvider({\n * create: () => ga4(credentials),\n * captured: () => fetchMock.mock.calls,\n * reset: () => { fetchMock.mockClear(); },\n * fail: () => { fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })); },\n * context,\n * });\n * });\n * ```\n */\nexport function conformsAsServerProvider(options: {\n create: () => ServerProvider;\n captured: () => unknown[];\n reset: () => void;\n fail: () => void;\n context: () => ResolvedContext;\n /** Already-resolved vendor target; conversion-ID providers use their configured ID. */\n target?: string;\n}): void {\n const { create, captured, reset, fail, context } = options;\n const target = options.target ?? 'purchase';\n const noop = (): void => {};\n\n assertIdentity(create());\n\n it('reports absent mappings on ignore providers and keeps explicit exclusions silent', async () => {\n const provider = create();\n if (provider.default !== 'ignore') return;\n reset();\n const errors: { severity: string; event: string }[] = [];\n const tracking = createServerTracking({\n providers: [{ ...provider, events: { explicit: null } }],\n onError: (error) => errors.push(error),\n });\n await tracking.track('missing', {}, context());\n await tracking.track('explicit', {}, context());\n expect(errors).toMatchObject([{ severity: 'warning', event: 'missing' }]);\n expect(captured()).toEqual([]);\n });\n\n it('sends the same thing twice for the same event', async () => {\n // The context carries a fixed timestamp, so any difference between these\n // two is something the provider produced on its own.\n reset();\n await create().track(target, STABLE_DATA, context(), noop);\n const first = JSON.stringify(captured());\n\n reset();\n await create().track(target, STABLE_DATA, context(), noop);\n\n expect(JSON.stringify(captured())).toBe(first);\n });\n\n it('forwards the deduplication id only when it was given one', async () => {\n reset();\n await create().track(target, STABLE_DATA, context(), noop);\n const without = JSON.stringify(captured());\n\n reset();\n await create().track(target, STABLE_DATA, { ...context(), dedupId: 'DEDUP-XYZ' }, noop);\n const with_ = JSON.stringify(captured());\n\n if (with_ !== without) expect(with_).toContain('DEDUP-XYZ');\n expect(without).not.toContain('DEDUP-XYZ');\n });\n\n it('throws a VendorResponseError when the vendor did not accept the send', async () => {\n // Adapters stay honest and loud. Swallowing a non-2xx is how a host ends\n // up believing it is measuring something, and the dispatcher is already\n // there to isolate the throw and report it. ADR-0013 adds the shape: what\n // reaches the host is recognised by `isVendorResponseError`, carrying the\n // status and the body, not just any throw. The body itself is\n // the caller's `fail()` hook to produce; a bodiless response is valid\n // (the documented example does exactly that), so it is not asserted here.\n reset();\n fail();\n\n let caught: unknown;\n try {\n await create().track(target, STABLE_DATA, context(), noop);\n } catch (error) {\n caught = error;\n }\n\n expect(isVendorResponseError(caught)).toBe(true);\n if (isVendorResponseError(caught)) {\n expect(typeof caught.status).toBe('number');\n expect(typeof caught.body).toBe('string');\n }\n });\n}\n"],"mappings":"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"}
|
|
@@ -21,12 +21,15 @@ function assertUniqueProviders(providers) {
|
|
|
21
21
|
* event identity is minted per account in its own dashboard, so there is no
|
|
22
22
|
* name that could possibly be guessed.
|
|
23
23
|
*/
|
|
24
|
-
function resolveEvent(provider, name) {
|
|
24
|
+
function resolveEvent(provider, name, report) {
|
|
25
25
|
const events = provider.events;
|
|
26
26
|
const binding = events && Object.hasOwn(events, name) ? events[name] : void 0;
|
|
27
27
|
if (binding === null) return void 0;
|
|
28
28
|
if (binding !== void 0) return binding;
|
|
29
|
-
if (provider.default === "ignore")
|
|
29
|
+
if (provider.default === "ignore") {
|
|
30
|
+
report?.("event omitted: no conversion mapping; add a binding or null for an explicit exclusion");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
30
33
|
return name;
|
|
31
34
|
}
|
|
32
35
|
/**
|
|
@@ -48,4 +51,4 @@ function messageOf(cause) {
|
|
|
48
51
|
//#endregion
|
|
49
52
|
export { resolveEvent as i, makeReporter as n, messageOf as r, assertUniqueProviders as t };
|
|
50
53
|
|
|
51
|
-
//# sourceMappingURL=dispatch-
|
|
54
|
+
//# sourceMappingURL=dispatch-B32jGhlh.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dispatch-B32jGhlh.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 report?: (message: string) => void,\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') {\n report?.(\n 'event omitted: no conversion mapping; add a binding or null for an explicit exclusion',\n );\n return undefined;\n }\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,MACA,QACoB;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;EACjC,SACE,uFACF;EACA;CACF;CAEA,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"}
|