tracklane 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +65 -5
- package/dist/{browser-BQNJ4qUq.d.ts → browser-usMUxJhU.d.ts} +8 -6
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +3 -3
- package/dist/browser.js.map +1 -1
- package/dist/{dispatch-DK2GBnkt.js → dispatch-BLBoCn-b.js} +2 -2
- package/dist/dispatch-BLBoCn-b.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/{server-cNxLsKhl.d.ts → server-DRda44Tb.d.ts} +22 -8
- package/dist/server.d.ts +1 -1
- package/dist/server.js +9 -9
- package/dist/server.js.map +1 -1
- package/dist/{vocabulary-ByyXHVWd.d.ts → vocabulary-fzM9ZuE1.d.ts} +33 -11
- package/package.json +13 -13
- package/dist/dispatch-DK2GBnkt.js.map +0 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bruno Bertolini
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# tracklane
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> One interface between your application and every tool that receives events.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Every analytics and advertising tool arrives with its own SDK, its own event names and its own
|
|
6
|
+
payload format. Add a second one and the tracking call at each conversion point stops being a line
|
|
7
|
+
and becomes a block. Add a third and every one of those blocks has to change.
|
|
8
|
+
|
|
9
|
+
`tracklane` gives your application one interface. Name what happened once, in GA4's vocabulary,
|
|
10
|
+
and it reaches every tool you configured, in the browser and through their server-side conversion
|
|
11
|
+
APIs.
|
|
12
|
+
|
|
13
|
+
**Documentation:** https://tracklane.codar.me
|
|
6
14
|
|
|
7
15
|
## Install
|
|
8
16
|
|
|
@@ -10,14 +18,66 @@ ESM-only TypeScript library. Documentation: https://brunobertolini.github.io/tra
|
|
|
10
18
|
pnpm add tracklane
|
|
11
19
|
```
|
|
12
20
|
|
|
13
|
-
|
|
21
|
+
Node `>=22.14.0`, ESM only.
|
|
22
|
+
|
|
23
|
+
## Browser
|
|
24
|
+
|
|
25
|
+
The vendor's own tag goes on your page first, the way the vendor documents it. This library talks
|
|
26
|
+
to the tag that is there. It does not inject third-party scripts.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { createTracking, ga4 } from 'tracklane/browser';
|
|
30
|
+
|
|
31
|
+
const { track, identify, consent } = createTracking({
|
|
32
|
+
providers: [ga4('G-XXXXXXX')],
|
|
33
|
+
onError: (error) => report(error),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
track('purchase', { transaction_id: 'T-1024', value: 49.9, currency: 'BRL' });
|
|
37
|
+
identify({ userId: 'user_42', email: 'ana@example.com' }, { plan: 'pro' });
|
|
38
|
+
consent('update', { ad_storage: 'granted', analytics_storage: 'granted' });
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Server
|
|
14
42
|
|
|
15
43
|
```ts
|
|
16
|
-
import {
|
|
44
|
+
import { createTracking, ga4 } from 'tracklane/server';
|
|
45
|
+
|
|
46
|
+
const { track } = createTracking({
|
|
47
|
+
providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret })],
|
|
48
|
+
});
|
|
17
49
|
|
|
18
|
-
|
|
50
|
+
await track('purchase', order, {
|
|
51
|
+
cookies: request.headers.get('cookie'),
|
|
52
|
+
user: { userId: order.userId, email: order.email },
|
|
53
|
+
dedupId: order.id,
|
|
54
|
+
timestamp: order.paidAt,
|
|
55
|
+
});
|
|
19
56
|
```
|
|
20
57
|
|
|
58
|
+
Pass the request's cookies: the vendors' own tags set them in the browser, and their server APIs
|
|
59
|
+
need what is inside. This library reads them for you, because that parsing is the part that fails
|
|
60
|
+
silently.
|
|
61
|
+
|
|
62
|
+
## What it does not do
|
|
63
|
+
|
|
64
|
+
It standardises and organises, and it never changes behaviour. The test applied to every decision
|
|
65
|
+
is *if you did not have this library, how would you write this line by hand?* It replicates
|
|
66
|
+
exactly that.
|
|
67
|
+
|
|
68
|
+
So it is not a consent platform: it forwards the consent call you make, when you make it, and
|
|
69
|
+
decides nothing. It is not a compliance layer, a validator, or a delivery guarantee. There is no
|
|
70
|
+
queue, no retry, and one vendor failing never affects another or your call site. And it does not
|
|
71
|
+
install vendor tags.
|
|
72
|
+
|
|
73
|
+
## Providers
|
|
74
|
+
|
|
75
|
+
**GA4** ships today, on both surfaces. **Meta, LinkedIn, PostHog and X** are next.
|
|
76
|
+
|
|
77
|
+
Any tool that receives events about what your users do can be a destination, and writing your own
|
|
78
|
+
uses the same public contract the built-in ones use, without a registry entry, an allow-list, or a
|
|
79
|
+
release of this library. See [writing a provider](https://tracklane.codar.me/docs/providers/custom).
|
|
80
|
+
|
|
21
81
|
## License
|
|
22
82
|
|
|
23
83
|
MIT © Bruno Bertolini
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as UserData, i as EventData, n as ConsentState, o as ProviderDefault, r as EventBinding, s as TrackingError } from "./vocabulary-
|
|
1
|
+
import { c as UserData, i as EventData, n as ConsentState, o as ProviderDefault, r as EventBinding, s as TrackingError } from "./vocabulary-fzM9ZuE1.js";
|
|
2
2
|
//#region src/providers/ga4.browser.d.ts
|
|
3
3
|
/** Optional configuration for the GA4 browser provider. */
|
|
4
4
|
interface Ga4BrowserConfig {
|
|
@@ -12,7 +12,7 @@ interface Ga4BrowserConfig {
|
|
|
12
12
|
* Google Analytics 4 in the browser, through `gtag.js`.
|
|
13
13
|
*
|
|
14
14
|
* **This provider does not load Google's tag.** The tag belongs on the page,
|
|
15
|
-
* installed the way Google documents it
|
|
15
|
+
* installed the way Google documents it: a snippet in the HTML, or a tag
|
|
16
16
|
* manager. This library talks to the tag that is already there; it never
|
|
17
17
|
* injects a third-party script into your page, and it never issues the
|
|
18
18
|
* property configuration that would emit a second page view.
|
|
@@ -51,7 +51,7 @@ declare function ga4(measurementId: string, config?: Ga4BrowserConfig): BrowserP
|
|
|
51
51
|
interface TrackOptions {
|
|
52
52
|
/**
|
|
53
53
|
* The value that lets a vendor recognise this hit and a server-side hit as
|
|
54
|
-
* one event. The library never invents it
|
|
54
|
+
* one event. The library never invents it: the browser and the server do
|
|
55
55
|
* not share a process, so only a value the host already owns (an order id)
|
|
56
56
|
* can match on both sides.
|
|
57
57
|
*/
|
|
@@ -60,8 +60,8 @@ interface TrackOptions {
|
|
|
60
60
|
/**
|
|
61
61
|
* A destination for browser events.
|
|
62
62
|
*
|
|
63
|
-
* This is the whole contract
|
|
64
|
-
*
|
|
63
|
+
* This is the whole contract. A provider written outside this package
|
|
64
|
+
* implements it exactly as the providers shipped here do.
|
|
65
65
|
*/
|
|
66
66
|
interface BrowserProvider<Target = string> {
|
|
67
67
|
/** The vendor's fixed name. Registering one twice is an error. */
|
|
@@ -72,6 +72,7 @@ interface BrowserProvider<Target = string> {
|
|
|
72
72
|
events?: Record<string, EventBinding<Target>>;
|
|
73
73
|
/** Loads the vendor tag. Runs once, at creation. */
|
|
74
74
|
install?(): void | Promise<void>;
|
|
75
|
+
/** Sends one event to this vendor. Throwing here reaches `onError`, not the caller. */
|
|
75
76
|
track(name: Target, data: EventData, options: TrackOptions): void;
|
|
76
77
|
/** Absent where the vendor keeps no user of its own. */
|
|
77
78
|
identify?(user: UserData, traits?: Record<string, unknown>): void;
|
|
@@ -80,6 +81,7 @@ interface BrowserProvider<Target = string> {
|
|
|
80
81
|
}
|
|
81
82
|
/** Options accepted by {@link createTracking}. */
|
|
82
83
|
interface BrowserTrackingOptions {
|
|
84
|
+
/** The vendors to send every event to. Registering the same `name` twice throws. */
|
|
83
85
|
providers: readonly BrowserProvider[];
|
|
84
86
|
/**
|
|
85
87
|
* The one diagnostic channel. Without it the library is silent, exactly as
|
|
@@ -121,4 +123,4 @@ interface BrowserTracking {
|
|
|
121
123
|
declare function createTracking(options: BrowserTrackingOptions): BrowserTracking;
|
|
122
124
|
//#endregion
|
|
123
125
|
export { createTracking as a, TrackOptions as i, BrowserTracking as n, Ga4BrowserConfig as o, BrowserTrackingOptions as r, ga4 as s, BrowserProvider as t };
|
|
124
|
-
//# sourceMappingURL=browser-
|
|
126
|
+
//# sourceMappingURL=browser-usMUxJhU.d.ts.map
|
package/dist/browser.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as createTracking, i as TrackOptions, n as BrowserTracking, o as Ga4BrowserConfig, r as BrowserTrackingOptions, s as ga4, t as BrowserProvider } from "./browser-
|
|
1
|
+
import { a as createTracking, i as TrackOptions, n as BrowserTracking, o as Ga4BrowserConfig, r as BrowserTrackingOptions, s as ga4, t as BrowserProvider } from "./browser-usMUxJhU.js";
|
|
2
2
|
export { BrowserProvider, BrowserTracking, BrowserTrackingOptions, type Ga4BrowserConfig, TrackOptions, createTracking, ga4 };
|
package/dist/browser.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as resolveEvent, n as makeReporter, r as messageOf, t as assertUniqueProviders } from "./dispatch-
|
|
1
|
+
import { i as resolveEvent, n as makeReporter, r as messageOf, t as assertUniqueProviders } from "./dispatch-BLBoCn-b.js";
|
|
2
2
|
//#region src/providers/ga4.browser.ts
|
|
3
3
|
function gtagWindow() {
|
|
4
4
|
return typeof window === "undefined" ? void 0 : window;
|
|
@@ -12,14 +12,14 @@ function gtagWindow() {
|
|
|
12
12
|
*/
|
|
13
13
|
function push(...args) {
|
|
14
14
|
const gtag = gtagWindow()?.gtag;
|
|
15
|
-
if (!gtag) throw new Error("ga4: window.gtag is not defined
|
|
15
|
+
if (!gtag) throw new Error("ga4: window.gtag is not defined. Add Google's tag to the page before creating tracking.");
|
|
16
16
|
gtag(...args);
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
19
|
* Google Analytics 4 in the browser, through `gtag.js`.
|
|
20
20
|
*
|
|
21
21
|
* **This provider does not load Google's tag.** The tag belongs on the page,
|
|
22
|
-
* installed the way Google documents it
|
|
22
|
+
* installed the way Google documents it: a snippet in the HTML, or a tag
|
|
23
23
|
* manager. This library talks to the tag that is already there; it never
|
|
24
24
|
* injects a third-party script into your page, and it never issues the
|
|
25
25
|
* property configuration that would emit a second page view.
|
package/dist/browser.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser.js","names":[],"sources":["../src/providers/ga4.browser.ts","../src/browser.ts"],"sourcesContent":["import type { BrowserProvider, TrackOptions } from '../browser.js';\nimport type { ConsentState, EventBinding, EventData, UserData } from '../vocabulary.js';\n\ninterface GtagWindow {\n gtag?: (...args: unknown[]) => void;\n}\n\n/** Optional configuration for the GA4 browser provider. */\nexport interface Ga4BrowserConfig {\n /**\n * How this property spells each canonical event. Rarely needed: the\n * canonical vocabulary already is GA4's.\n */\n events?: Record<string, EventBinding<string>>;\n}\n\n// Server rendering reaches this module with no browser at all, where\n// `window` is a ReferenceError rather than `undefined`.\nfunction gtagWindow(): GtagWindow | undefined {\n return typeof window === 'undefined' ? undefined : (window as unknown as GtagWindow);\n}\n\n/**\n * Sends a command, or fails the way a hand-written call would.\n *\n * Writing `gtag(…)` on a page without Google's snippet throws, and this\n * library replicates that rather than swallowing it: a silent no-op is how a\n * host ends up believing it is measuring something.\n */\nfunction push(...args: unknown[]): void {\n const gtag = gtagWindow()?.gtag;\n\n if (!gtag) {\n throw new Error(\n \"ga4: window.gtag is not defined — add Google's tag to the page before creating tracking\",\n );\n }\n\n gtag(...args);\n}\n\n/**\n * Google Analytics 4 in the browser, through `gtag.js`.\n *\n * **This provider does not load Google's tag.** The tag belongs on the page,\n * installed the way Google documents it — a snippet in the HTML, or a tag\n * manager. This library talks to the tag that is already there; it never\n * injects a third-party script into your page, and it never issues the\n * property configuration that would emit a second page view.\n *\n * Which also means the initial consent declaration is not ours to make:\n * Google requires it before the property is configured, and the snippet that\n * configures the property is yours. Use {@link ConsentState} through\n * `consent()` for changes after that.\n *\n * @param measurementId - The `G-` id of the data stream, matching the tag on\n * the page.\n * @param config - Rarely needed event name overrides.\n * @returns A provider for `createTracking` from `tracklane/browser`.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/browser';\n *\n * // Google's own snippet is already in the page.\n * const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });\n *\n * track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });\n * ```\n */\nexport function ga4(measurementId: string, config: Ga4BrowserConfig = {}): BrowserProvider {\n const { events } = config;\n\n return {\n name: 'ga4',\n // The canonical names already are GA4's, so every event passes through\n // under its own name unless the host says otherwise.\n default: 'passthrough',\n ...(events ? { events } : {}),\n\n track(name: string, data: EventData, _options: TrackOptions): void {\n // GA4 documents no browser-to-server deduplication, so `dedupId` maps\n // to nothing here. That is the exact translation of \"this concept does\n // not exist in this vendor\" — inventing a param would imply a merge\n // the platform never performs.\n //\n // `send_to` is written after the host's data on purpose: without it,\n // gtag sends to every configured property on the page, so the\n // addressing is the adapter's to own and not the host's to override.\n push('event', name, { ...data, send_to: measurementId });\n },\n\n identify(user: UserData, traits?: Record<string, unknown>): void {\n // GA4 documents no browser slot for email, phone or names. They map to\n // nothing, which is what \"this concept does not exist here\" means.\n if (user.userId !== undefined) push('set', { user_id: user.userId });\n if (traits) push('set', 'user_properties', traits);\n },\n\n consent(command: 'default' | 'update', state: ConsentState): void {\n // Verbatim: the library's consent vocabulary is Google's own spelling,\n // so there is nothing to translate and no chance to expand a signal\n // the host did not declare.\n push('consent', command, state);\n },\n };\n}\n","import { assertUniqueProviders, makeReporter, messageOf, resolveEvent } from './dispatch.js';\n\nexport { type Ga4BrowserConfig, ga4 } from './providers/ga4.browser.js';\n\nimport type {\n ConsentState,\n EventBinding,\n EventData,\n ProviderDefault,\n TrackingError,\n UserData,\n} from './vocabulary.js';\n\n/**\n * Everything the browser needs beyond the event itself.\n *\n * There is one field, and that is the point: in the browser each vendor tag\n * reads its own cookies and holds its own session, so the only thing left\n * for the host to supply is the value that ties this hit to a server-side\n * one.\n */\nexport interface TrackOptions {\n /**\n * The value that lets a vendor recognise this hit and a server-side hit as\n * one event. The library never invents it — the browser and the server do\n * not share a process, so only a value the host already owns (an order id)\n * can match on both sides.\n */\n dedupId?: string;\n}\n\n/**\n * A destination for browser events.\n *\n * This is the whole contract, and it is what a provider written outside this\n * package implements — the ones shipped here get no shortcut.\n */\nexport interface BrowserProvider<Target = string> {\n /** The vendor's fixed name. Registering one twice is an error. */\n name: string;\n /** What happens to a canonical event this vendor has no binding for. */\n default: ProviderDefault;\n /** How this vendor spells each canonical event. */\n events?: Record<string, EventBinding<Target>>;\n /** Loads the vendor tag. Runs once, at creation. */\n install?(): void | Promise<void>;\n 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 providers: readonly BrowserProvider[];\n /**\n * The one diagnostic channel. Without it the library is silent, exactly as\n * a hand-written vendor call is.\n */\n onError?: (error: TrackingError) => void;\n}\n\n/** What {@link createTracking} returns. */\nexport interface BrowserTracking {\n /** Sends one event to every configured vendor. Never throws. */\n track(name: string, data?: EventData, options?: TrackOptions): void;\n /** Forwards the user to the vendors that hold one, and to nobody else. */\n identify(user: UserData, traits?: Record<string, unknown>): void;\n /**\n * Forwards a consent declaration to every vendor that documents a command\n * for it. It never decides, stores or withholds anything.\n */\n consent(command: 'default' | 'update', state: ConsentState): void;\n}\n\n/**\n * Creates the browser tracking surface.\n *\n * Providers install eagerly, which is what pasting a vendor snippet does and\n * what lets each vendor record its own initial page view. Every vendor\n * shipped here queues its own commands until its script lands, so nothing is\n * lost to loading and this library needs no queue of its own.\n *\n * @param options - The providers to send to, and an optional error channel.\n * @returns The `track`, `identify` and `consent` calls.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/browser';\n *\n * const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });\n * track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });\n * ```\n */\nexport function createTracking(options: BrowserTrackingOptions): BrowserTracking {\n const { providers } = options;\n assertUniqueProviders(providers);\n\n const report = makeReporter(options.onError);\n\n const fail = (provider: string, event: string, cause: unknown): void => {\n report({ provider, event, severity: 'error', message: messageOf(cause), cause });\n };\n\n for (const provider of providers) {\n try {\n const installing = provider.install?.();\n // An async loader that rejects would otherwise become an unhandled\n // rejection — the silent failure this channel exists to prevent.\n void Promise.resolve(installing).catch((cause: unknown) => {\n fail(provider.name, 'install', cause);\n });\n } catch (cause) {\n fail(provider.name, 'install', cause);\n }\n }\n\n return {\n track(name, data = {}, trackOptions = {}) {\n for (const provider of providers) {\n const target = resolveEvent(provider, name);\n if (target === undefined) continue;\n\n try {\n provider.track(target, data, trackOptions);\n } catch (cause) {\n fail(provider.name, name, cause);\n }\n }\n },\n\n identify(user, traits) {\n for (const provider of providers) {\n try {\n provider.identify?.(user, traits);\n } catch (cause) {\n fail(provider.name, 'identify', cause);\n }\n }\n },\n\n consent(command, state) {\n for (const provider of providers) {\n try {\n provider.consent?.(command, state);\n } catch (cause) {\n fail(provider.name, 'consent', cause);\n }\n }\n },\n };\n}\n"],"mappings":";;AAkBA,SAAS,aAAqC;CAC5C,OAAO,OAAO,WAAW,cAAc,KAAA,IAAa;AACtD;;;;;;;;AASA,SAAS,KAAK,GAAG,MAAuB;CACtC,MAAM,OAAO,WAAW,CAAC,EAAE;CAE3B,IAAI,CAAC,MACH,MAAM,IAAI,MACR,yFACF;CAGF,KAAK,GAAG,IAAI;AACd;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,SAAgB,IAAI,eAAuB,SAA2B,CAAC,GAAoB;CACzF,MAAM,EAAE,WAAW;CAEnB,OAAO;EACL,MAAM;EAGN,SAAS;EACT,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;EAE3B,MAAM,MAAc,MAAiB,UAA8B;GASjE,KAAK,SAAS,MAAM;IAAE,GAAG;IAAM,SAAS;GAAc,CAAC;EACzD;EAEA,SAAS,MAAgB,QAAwC;GAG/D,IAAI,KAAK,WAAW,KAAA,GAAW,KAAK,OAAO,EAAE,SAAS,KAAK,OAAO,CAAC;GACnE,IAAI,QAAQ,KAAK,OAAO,mBAAmB,MAAM;EACnD;EAEA,QAAQ,SAA+B,OAA2B;GAIhE,KAAK,WAAW,SAAS,KAAK;EAChC;CACF;AACF;;;;;;;;;;;;;;;;;;;;;;ACXA,SAAgB,eAAe,SAAkD;CAC/E,MAAM,EAAE,cAAc;CACtB,sBAAsB,SAAS;CAE/B,MAAM,SAAS,aAAa,QAAQ,OAAO;CAE3C,MAAM,QAAQ,UAAkB,OAAe,UAAyB;EACtE,OAAO;GAAE;GAAU;GAAO,UAAU;GAAS,SAAS,UAAU,KAAK;GAAG;EAAM,CAAC;CACjF;CAEA,KAAK,MAAM,YAAY,WACrB,IAAI;EACF,MAAM,aAAa,SAAS,UAAU;EAGtC,QAAa,QAAQ,UAAU,CAAC,CAAC,OAAO,UAAmB;GACzD,KAAK,SAAS,MAAM,WAAW,KAAK;EACtC,CAAC;CACH,SAAS,OAAO;EACd,KAAK,SAAS,MAAM,WAAW,KAAK;CACtC;CAGF,OAAO;EACL,MAAM,MAAM,OAAO,CAAC,GAAG,eAAe,CAAC,GAAG;GACxC,KAAK,MAAM,YAAY,WAAW;IAChC,MAAM,SAAS,aAAa,UAAU,IAAI;IAC1C,IAAI,WAAW,KAAA,GAAW;IAE1B,IAAI;KACF,SAAS,MAAM,QAAQ,MAAM,YAAY;IAC3C,SAAS,OAAO;KACd,KAAK,SAAS,MAAM,MAAM,KAAK;IACjC;GACF;EACF;EAEA,SAAS,MAAM,QAAQ;GACrB,KAAK,MAAM,YAAY,WACrB,IAAI;IACF,SAAS,WAAW,MAAM,MAAM;GAClC,SAAS,OAAO;IACd,KAAK,SAAS,MAAM,YAAY,KAAK;GACvC;EAEJ;EAEA,QAAQ,SAAS,OAAO;GACtB,KAAK,MAAM,YAAY,WACrB,IAAI;IACF,SAAS,UAAU,SAAS,KAAK;GACnC,SAAS,OAAO;IACd,KAAK,SAAS,MAAM,WAAW,KAAK;GACtC;EAEJ;CACF;AACF"}
|
|
1
|
+
{"version":3,"file":"browser.js","names":[],"sources":["../src/providers/ga4.browser.ts","../src/browser.ts"],"sourcesContent":["import type { BrowserProvider, TrackOptions } from '../browser.js';\nimport type { ConsentState, EventBinding, EventData, UserData } from '../vocabulary.js';\n\ninterface GtagWindow {\n gtag?: (...args: unknown[]) => void;\n}\n\n/** Optional configuration for the GA4 browser provider. */\nexport interface Ga4BrowserConfig {\n /**\n * How this property spells each canonical event. Rarely needed: the\n * canonical vocabulary already is GA4's.\n */\n events?: Record<string, EventBinding<string>>;\n}\n\n// Server rendering reaches this module with no browser at all, where\n// `window` is a ReferenceError rather than `undefined`.\nfunction gtagWindow(): GtagWindow | undefined {\n return typeof window === 'undefined' ? undefined : (window as unknown as GtagWindow);\n}\n\n/**\n * Sends a command, or fails the way a hand-written call would.\n *\n * Writing `gtag(…)` on a page without Google's snippet throws, and this\n * library replicates that rather than swallowing it: a silent no-op is how a\n * host ends up believing it is measuring something.\n */\nfunction push(...args: unknown[]): void {\n const gtag = gtagWindow()?.gtag;\n\n if (!gtag) {\n throw new Error(\n \"ga4: window.gtag is not defined. Add Google's tag to the page before creating tracking.\",\n );\n }\n\n gtag(...args);\n}\n\n/**\n * Google Analytics 4 in the browser, through `gtag.js`.\n *\n * **This provider does not load Google's tag.** The tag belongs on the page,\n * installed the way Google documents it: a snippet in the HTML, or a tag\n * manager. This library talks to the tag that is already there; it never\n * injects a third-party script into your page, and it never issues the\n * property configuration that would emit a second page view.\n *\n * Which also means the initial consent declaration is not ours to make:\n * Google requires it before the property is configured, and the snippet that\n * configures the property is yours. Use {@link ConsentState} through\n * `consent()` for changes after that.\n *\n * @param measurementId - The `G-` id of the data stream, matching the tag on\n * the page.\n * @param config - Rarely needed event name overrides.\n * @returns A provider for `createTracking` from `tracklane/browser`.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/browser';\n *\n * // Google's own snippet is already in the page.\n * const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });\n *\n * track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });\n * ```\n */\nexport function ga4(measurementId: string, config: Ga4BrowserConfig = {}): BrowserProvider {\n const { events } = config;\n\n return {\n name: 'ga4',\n // The canonical names already are GA4's, so every event passes through\n // under its own name unless the host says otherwise.\n default: 'passthrough',\n ...(events ? { events } : {}),\n\n track(name: string, data: EventData, _options: TrackOptions): void {\n // GA4 documents no browser-to-server deduplication, so `dedupId` maps\n // to nothing here. That is the exact translation of \"this concept does\n // not exist in this vendor\". Inventing a param would imply a merge\n // the platform never performs.\n //\n // `send_to` is written after the host's data on purpose: without it,\n // gtag sends to every configured property on the page, so the\n // addressing is the adapter's to own and not the host's to override.\n push('event', name, { ...data, send_to: measurementId });\n },\n\n identify(user: UserData, traits?: Record<string, unknown>): void {\n // GA4 documents no browser slot for email, phone or names. They map to\n // nothing, which is what \"this concept does not exist here\" means.\n if (user.userId !== undefined) push('set', { user_id: user.userId });\n if (traits) push('set', 'user_properties', traits);\n },\n\n consent(command: 'default' | 'update', state: ConsentState): void {\n // Verbatim: the library's consent vocabulary is Google's own spelling,\n // so there is nothing to translate and no chance to expand a signal\n // the host did not declare.\n push('consent', command, state);\n },\n };\n}\n","import { assertUniqueProviders, makeReporter, messageOf, resolveEvent } from './dispatch.js';\n\nexport { type Ga4BrowserConfig, ga4 } from './providers/ga4.browser.js';\n\nimport type {\n ConsentState,\n EventBinding,\n EventData,\n ProviderDefault,\n TrackingError,\n UserData,\n} from './vocabulary.js';\n\n/**\n * Everything the browser needs beyond the event itself.\n *\n * There is one field, and that is the point: in the browser each vendor tag\n * reads its own cookies and holds its own session, so the only thing left\n * for the host to supply is the value that ties this hit to a server-side\n * one.\n */\nexport interface TrackOptions {\n /**\n * The value that lets a vendor recognise this hit and a server-side hit as\n * one event. The library never invents it: the browser and the server do\n * not share a process, so only a value the host already owns (an order id)\n * can match on both sides.\n */\n dedupId?: string;\n}\n\n/**\n * A destination for browser events.\n *\n * This is the whole contract. A provider written outside this package\n * implements it exactly as the providers shipped here do.\n */\nexport interface BrowserProvider<Target = string> {\n /** The vendor's fixed name. Registering one twice is an error. */\n name: string;\n /** What happens to a canonical event this vendor has no binding for. */\n default: ProviderDefault;\n /** How this vendor spells each canonical event. */\n events?: Record<string, EventBinding<Target>>;\n /** Loads the vendor tag. Runs once, at creation. */\n install?(): void | Promise<void>;\n /** Sends one event to this vendor. Throwing here reaches `onError`, not the caller. */\n track(name: Target, data: EventData, options: TrackOptions): void;\n /** Absent where the vendor keeps no user of its own. */\n identify?(user: UserData, traits?: Record<string, unknown>): void;\n /** Absent where the vendor documents no consent command. */\n consent?(command: 'default' | 'update', state: ConsentState): void;\n}\n\n/** Options accepted by {@link createTracking}. */\nexport interface BrowserTrackingOptions {\n /** The vendors to send every event to. Registering the same `name` twice throws. */\n providers: readonly BrowserProvider[];\n /**\n * The one diagnostic channel. Without it the library is silent, exactly as\n * a hand-written vendor call is.\n */\n onError?: (error: TrackingError) => void;\n}\n\n/** What {@link createTracking} returns. */\nexport interface BrowserTracking {\n /** Sends one event to every configured vendor. Never throws. */\n track(name: string, data?: EventData, options?: TrackOptions): void;\n /** Forwards the user to the vendors that hold one, and to nobody else. */\n identify(user: UserData, traits?: Record<string, unknown>): void;\n /**\n * Forwards a consent declaration to every vendor that documents a command\n * for it. It never decides, stores or withholds anything.\n */\n consent(command: 'default' | 'update', state: ConsentState): void;\n}\n\n/**\n * Creates the browser tracking surface.\n *\n * Providers install eagerly, which is what pasting a vendor snippet does and\n * what lets each vendor record its own initial page view. Every vendor\n * shipped here queues its own commands until its script lands, so nothing is\n * lost to loading and this library needs no queue of its own.\n *\n * @param options - The providers to send to, and an optional error channel.\n * @returns The `track`, `identify` and `consent` calls.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/browser';\n *\n * const { track } = createTracking({ providers: [ga4('G-XXXXXXX')] });\n * track('purchase', { transaction_id: 'T-1', value: 49.9, currency: 'BRL' });\n * ```\n */\nexport function createTracking(options: BrowserTrackingOptions): BrowserTracking {\n const { providers } = options;\n assertUniqueProviders(providers);\n\n const report = makeReporter(options.onError);\n\n const fail = (provider: string, event: string, cause: unknown): void => {\n report({ provider, event, severity: 'error', message: messageOf(cause), cause });\n };\n\n for (const provider of providers) {\n try {\n const installing = provider.install?.();\n // An async loader that rejects would otherwise become an unhandled\n // rejection, the silent failure this channel exists to prevent.\n void Promise.resolve(installing).catch((cause: unknown) => {\n fail(provider.name, 'install', cause);\n });\n } catch (cause) {\n fail(provider.name, 'install', cause);\n }\n }\n\n return {\n track(name, data = {}, trackOptions = {}) {\n for (const provider of providers) {\n const target = resolveEvent(provider, name);\n if (target === undefined) continue;\n\n try {\n provider.track(target, data, trackOptions);\n } catch (cause) {\n fail(provider.name, name, cause);\n }\n }\n },\n\n identify(user, traits) {\n for (const provider of providers) {\n try {\n provider.identify?.(user, traits);\n } catch (cause) {\n fail(provider.name, 'identify', cause);\n }\n }\n },\n\n consent(command, state) {\n for (const provider of providers) {\n try {\n provider.consent?.(command, state);\n } catch (cause) {\n fail(provider.name, 'consent', cause);\n }\n }\n },\n };\n}\n"],"mappings":";;AAkBA,SAAS,aAAqC;CAC5C,OAAO,OAAO,WAAW,cAAc,KAAA,IAAa;AACtD;;;;;;;;AASA,SAAS,KAAK,GAAG,MAAuB;CACtC,MAAM,OAAO,WAAW,CAAC,EAAE;CAE3B,IAAI,CAAC,MACH,MAAM,IAAI,MACR,yFACF;CAGF,KAAK,GAAG,IAAI;AACd;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,SAAgB,IAAI,eAAuB,SAA2B,CAAC,GAAoB;CACzF,MAAM,EAAE,WAAW;CAEnB,OAAO;EACL,MAAM;EAGN,SAAS;EACT,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;EAE3B,MAAM,MAAc,MAAiB,UAA8B;GASjE,KAAK,SAAS,MAAM;IAAE,GAAG;IAAM,SAAS;GAAc,CAAC;EACzD;EAEA,SAAS,MAAgB,QAAwC;GAG/D,IAAI,KAAK,WAAW,KAAA,GAAW,KAAK,OAAO,EAAE,SAAS,KAAK,OAAO,CAAC;GACnE,IAAI,QAAQ,KAAK,OAAO,mBAAmB,MAAM;EACnD;EAEA,QAAQ,SAA+B,OAA2B;GAIhE,KAAK,WAAW,SAAS,KAAK;EAChC;CACF;AACF;;;;;;;;;;;;;;;;;;;;;;ACTA,SAAgB,eAAe,SAAkD;CAC/E,MAAM,EAAE,cAAc;CACtB,sBAAsB,SAAS;CAE/B,MAAM,SAAS,aAAa,QAAQ,OAAO;CAE3C,MAAM,QAAQ,UAAkB,OAAe,UAAyB;EACtE,OAAO;GAAE;GAAU;GAAO,UAAU;GAAS,SAAS,UAAU,KAAK;GAAG;EAAM,CAAC;CACjF;CAEA,KAAK,MAAM,YAAY,WACrB,IAAI;EACF,MAAM,aAAa,SAAS,UAAU;EAGtC,QAAa,QAAQ,UAAU,CAAC,CAAC,OAAO,UAAmB;GACzD,KAAK,SAAS,MAAM,WAAW,KAAK;EACtC,CAAC;CACH,SAAS,OAAO;EACd,KAAK,SAAS,MAAM,WAAW,KAAK;CACtC;CAGF,OAAO;EACL,MAAM,MAAM,OAAO,CAAC,GAAG,eAAe,CAAC,GAAG;GACxC,KAAK,MAAM,YAAY,WAAW;IAChC,MAAM,SAAS,aAAa,UAAU,IAAI;IAC1C,IAAI,WAAW,KAAA,GAAW;IAE1B,IAAI;KACF,SAAS,MAAM,QAAQ,MAAM,YAAY;IAC3C,SAAS,OAAO;KACd,KAAK,SAAS,MAAM,MAAM,KAAK;IACjC;GACF;EACF;EAEA,SAAS,MAAM,QAAQ;GACrB,KAAK,MAAM,YAAY,WACrB,IAAI;IACF,SAAS,WAAW,MAAM,MAAM;GAClC,SAAS,OAAO;IACd,KAAK,SAAS,MAAM,YAAY,KAAK;GACvC;EAEJ;EAEA,QAAQ,SAAS,OAAO;GACtB,KAAK,MAAM,YAAY,WACrB,IAAI;IACF,SAAS,UAAU,SAAS,KAAK;GACnC,SAAS,OAAO;IACd,KAAK,SAAS,MAAM,WAAW,KAAK;GACtC;EAEJ;CACF;AACF"}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Registering one vendor twice is a hard error rather than a merge: there is
|
|
4
4
|
* nothing a second registration could say that the first cannot, and
|
|
5
|
-
* accepting both would double every event to that vendor
|
|
5
|
+
* accepting both would double every event to that vendor, a defect that
|
|
6
6
|
* surfaces weeks later as inflated conversions.
|
|
7
7
|
*/
|
|
8
8
|
function assertUniqueProviders(providers) {
|
|
@@ -48,4 +48,4 @@ function messageOf(cause) {
|
|
|
48
48
|
//#endregion
|
|
49
49
|
export { resolveEvent as i, makeReporter as n, messageOf as r, assertUniqueProviders as t };
|
|
50
50
|
|
|
51
|
-
//# sourceMappingURL=dispatch-
|
|
51
|
+
//# sourceMappingURL=dispatch-BLBoCn-b.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as EventItem, c as UserData, i as EventData, n as ConsentState, o as ProviderDefault, r as EventBinding, s as TrackingError, t as ActionSource } from "./vocabulary-
|
|
2
|
-
import { i as TrackOptions, n as BrowserTracking, r as BrowserTrackingOptions, t as BrowserProvider } from "./browser-
|
|
3
|
-
import { a as ServerTrackingOptions, i as ServerTracking, n as ResolvedContext, r as ServerProvider, t as EventContext } from "./server-
|
|
1
|
+
import { a as EventItem, c as UserData, i as EventData, n as ConsentState, o as ProviderDefault, r as EventBinding, s as TrackingError, t as ActionSource } from "./vocabulary-fzM9ZuE1.js";
|
|
2
|
+
import { i as TrackOptions, n as BrowserTracking, r as BrowserTrackingOptions, t as BrowserProvider } from "./browser-usMUxJhU.js";
|
|
3
|
+
import { a as ServerTrackingOptions, i as ServerTracking, n as ResolvedContext, r as ServerProvider, t as EventContext } from "./server-DRda44Tb.js";
|
|
4
4
|
export type { ActionSource, BrowserProvider, BrowserTracking, BrowserTrackingOptions, ConsentState, EventBinding, EventContext, EventData, EventItem, ProviderDefault, ResolvedContext, ServerProvider, ServerTracking, ServerTrackingOptions, TrackOptions, TrackingError, UserData };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as UserData, i as EventData, n as ConsentState, o as ProviderDefault, r as EventBinding, s as TrackingError, t as ActionSource } from "./vocabulary-
|
|
1
|
+
import { c as UserData, i as EventData, n as ConsentState, o as ProviderDefault, r as EventBinding, s as TrackingError, t as ActionSource } from "./vocabulary-fzM9ZuE1.js";
|
|
2
2
|
//#region src/providers/ga4.server.d.ts
|
|
3
3
|
/** Credentials for the GA4 Measurement Protocol. */
|
|
4
4
|
interface Ga4ServerCredentials {
|
|
@@ -37,15 +37,16 @@ declare function ga4(credentials: Ga4ServerCredentials, config?: Ga4ServerConfig
|
|
|
37
37
|
* is, when it happened, and where it was observed.
|
|
38
38
|
*
|
|
39
39
|
* None of this is "what happened", which is why it does not travel in the
|
|
40
|
-
* event data
|
|
40
|
+
* event data: putting it there would leak identity into the pass-through
|
|
41
41
|
* event params of the vendors that forward the payload as-is.
|
|
42
42
|
*/
|
|
43
43
|
interface EventContext {
|
|
44
|
+
/** The person's identity, the server-side counterpart to `identify()` on the browser. */
|
|
44
45
|
user?: UserData;
|
|
45
46
|
/**
|
|
46
47
|
* The request's raw `Cookie` header, or an already-parsed map.
|
|
47
48
|
*
|
|
48
|
-
* These are cookies the vendors' own browser tags set
|
|
49
|
+
* These are cookies the vendors' own browser tags set. This library never
|
|
49
50
|
* writes one. On the server each adapter reads the cookies belonging to
|
|
50
51
|
* its own vendor: Meta forwards its value as-is, Google's holds several
|
|
51
52
|
* things at once and has to be opened. That knowledge lives in the
|
|
@@ -68,8 +69,11 @@ interface EventContext {
|
|
|
68
69
|
timestamp?: number | Date;
|
|
69
70
|
/** Overrides the factory default. Meta requires it on every event. */
|
|
70
71
|
source?: ActionSource;
|
|
72
|
+
/** The page the conversion happened on. Meta's Conversions API reads it as `event_source_url`. */
|
|
71
73
|
url?: string;
|
|
74
|
+
/** The visitor's IP. Meta's Conversions API reads it as one of the identifiers in `user_data`. */
|
|
72
75
|
ip?: string;
|
|
76
|
+
/** The visitor's user agent. Meta's Conversions API reads it alongside `ip`. */
|
|
73
77
|
userAgent?: string;
|
|
74
78
|
/** What is known *about* the person: GA4 user properties, PostHog `$set`. */
|
|
75
79
|
traits?: Record<string, unknown>;
|
|
@@ -82,6 +86,7 @@ interface EventContext {
|
|
|
82
86
|
}
|
|
83
87
|
/** The context an adapter receives, with the ambiguity resolved. */
|
|
84
88
|
interface ResolvedContext extends Omit<EventContext, "cookies" | "timestamp"> {
|
|
89
|
+
/** The request's cookies, already split into a `name → value` map. */
|
|
85
90
|
cookies: Record<string, string>;
|
|
86
91
|
/** Epoch milliseconds. Adapters convert to their vendor's spelling. */
|
|
87
92
|
timestamp: number;
|
|
@@ -89,23 +94,32 @@ interface ResolvedContext extends Omit<EventContext, "cookies" | "timestamp"> {
|
|
|
89
94
|
/**
|
|
90
95
|
* A destination for server events.
|
|
91
96
|
*
|
|
92
|
-
* `track` may throw
|
|
97
|
+
* `track` may throw, on a non-2xx or where a vendor documents a
|
|
93
98
|
* precondition the event cannot meet. The dispatcher isolates and reports
|
|
94
99
|
* it, so adapters stay honest and loud.
|
|
95
100
|
*
|
|
96
|
-
* `report` is for a caveat about a request that already succeeded. It
|
|
97
|
-
*
|
|
101
|
+
* `report` is for a caveat about a request that already succeeded. It does
|
|
102
|
+
* not throw: by then the send happened, and throwing would tell a host's
|
|
98
103
|
* retry logic to resend a delivered conversion.
|
|
99
104
|
*/
|
|
100
105
|
interface ServerProvider<Target = string> {
|
|
106
|
+
/** The vendor's fixed name. Registering one twice is an error. */
|
|
101
107
|
name: string;
|
|
108
|
+
/** What happens to a canonical event this vendor has no binding for. */
|
|
102
109
|
default: ProviderDefault;
|
|
110
|
+
/** How this vendor spells each canonical event. */
|
|
103
111
|
events?: Record<string, EventBinding<Target>>;
|
|
112
|
+
/** Sends one event to this vendor's conversion API. May throw, or call `report` for a caveat. */
|
|
104
113
|
track(name: Target, data: EventData, context: ResolvedContext, report: (message: string) => void): Promise<void>;
|
|
105
114
|
}
|
|
106
115
|
/** Options accepted by {@link createTracking}. */
|
|
107
116
|
interface ServerTrackingOptions {
|
|
117
|
+
/** The vendors to send every event to. Registering the same `name` twice throws. */
|
|
108
118
|
providers: readonly ServerProvider[];
|
|
119
|
+
/**
|
|
120
|
+
* The one diagnostic channel. Without it the library is silent, exactly as
|
|
121
|
+
* a hand-written vendor call is.
|
|
122
|
+
*/
|
|
109
123
|
onError?: (error: TrackingError) => void;
|
|
110
124
|
}
|
|
111
125
|
/** What {@link createTracking} returns. */
|
|
@@ -128,7 +142,7 @@ interface ServerTracking {
|
|
|
128
142
|
* one visitor to another visitor's conversion. Identity travels with the
|
|
129
143
|
* call, which is also what every conversion API expects.
|
|
130
144
|
*
|
|
131
|
-
* There is no `consent` call either
|
|
145
|
+
* There is no `consent` call either. Consent commands are properties of a
|
|
132
146
|
* vendor's on-page tag, and a server request has no tag. The one vendor that
|
|
133
147
|
* accepts consent server-side reads it from the context.
|
|
134
148
|
*
|
|
@@ -149,4 +163,4 @@ interface ServerTracking {
|
|
|
149
163
|
declare function createTracking(options: ServerTrackingOptions): ServerTracking;
|
|
150
164
|
//#endregion
|
|
151
165
|
export { ServerTrackingOptions as a, Ga4ServerCredentials as c, ServerTracking as i, ga4 as l, ResolvedContext as n, createTracking as o, ServerProvider as r, Ga4ServerConfig as s, EventContext as t };
|
|
152
|
-
//# sourceMappingURL=server-
|
|
166
|
+
//# sourceMappingURL=server-DRda44Tb.d.ts.map
|
package/dist/server.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as ServerTrackingOptions, c as Ga4ServerCredentials, i as ServerTracking, l as ga4, n as ResolvedContext, o as createTracking, r as ServerProvider, s as Ga4ServerConfig, t as EventContext } from "./server-
|
|
1
|
+
import { a as ServerTrackingOptions, c as Ga4ServerCredentials, i as ServerTracking, l as ga4, n as ResolvedContext, o as createTracking, r as ServerProvider, s as Ga4ServerConfig, t as EventContext } from "./server-DRda44Tb.js";
|
|
2
2
|
export { EventContext, type Ga4ServerConfig, type Ga4ServerCredentials, ResolvedContext, ServerProvider, ServerTracking, ServerTrackingOptions, createTracking, ga4 };
|
package/dist/server.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { i as resolveEvent, n as makeReporter, r as messageOf, t as assertUniqueProviders } from "./dispatch-
|
|
1
|
+
import { i as resolveEvent, n as makeReporter, r as messageOf, t as assertUniqueProviders } from "./dispatch-BLBoCn-b.js";
|
|
2
2
|
//#region src/providers/ga4.server.ts
|
|
3
3
|
const ENDPOINT = "https://www.google-analytics.com/mp/collect";
|
|
4
4
|
/**
|
|
5
5
|
* The visitor cookie holds `GA1.<depth>.<client_id>`, and the client id
|
|
6
|
-
* itself contains a dot
|
|
6
|
+
* itself contains a dot, so the identifier is the last two segments, which
|
|
7
7
|
* is what survives Google's varying prefix depth.
|
|
8
8
|
*
|
|
9
9
|
* The value must be the cookie as the browser wrote it. A host passing a
|
|
@@ -21,14 +21,14 @@ function visitorId(cookies) {
|
|
|
21
21
|
*
|
|
22
22
|
* Verified against Google's live tag on 2026-07-31 rather than assumed:
|
|
23
23
|
*
|
|
24
|
-
* - `GS2.1.s1785511401$o1$g0$t1785511401$j60$l0$h0
|
|
24
|
+
* - `GS2.1.s1785511401$o1$g0$t1785511401$j60$l0$h0`, the current one. The
|
|
25
25
|
* third dot-segment packs several fields joined by `$`, and the session is
|
|
26
26
|
* the first of them, prefixed with `s`.
|
|
27
|
-
* - `GS1.1.1785511401.1.1
|
|
27
|
+
* - `GS1.1.1785511401.1.1.…`, the older one, where the third dot-segment is
|
|
28
28
|
* the session on its own.
|
|
29
29
|
*
|
|
30
30
|
* Reading the third segment blindly works for the old layout and returns the
|
|
31
|
-
* whole `s…$o1$g0$…` blob for the new one
|
|
31
|
+
* whole `s…$o1$g0$…` blob for the new one, which the vendor would accept and
|
|
32
32
|
* silently fail to match. Hence the two shapes, and hence `undefined` rather
|
|
33
33
|
* than a guess when neither fits.
|
|
34
34
|
*/
|
|
@@ -41,7 +41,7 @@ function sessionId(cookies, measurementId) {
|
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
43
|
* The Measurement Protocol documents exactly two consent fields, and spells
|
|
44
|
-
* their values in upper case where the browser spells them in lower
|
|
44
|
+
* their values in upper case where the browser spells them in lower, one of
|
|
45
45
|
* the small asymmetries an adapter absorbs so nobody else has to.
|
|
46
46
|
*/
|
|
47
47
|
function consentBody(state) {
|
|
@@ -77,7 +77,7 @@ function ga4(credentials, config = {}) {
|
|
|
77
77
|
...config.events ? { events: config.events } : {},
|
|
78
78
|
async track(name, data, context, report) {
|
|
79
79
|
const clientId = visitorId(context.cookies);
|
|
80
|
-
if (!clientId) throw new Error("ga4: cannot build client_id
|
|
80
|
+
if (!clientId) throw new Error("ga4: cannot build client_id, no _ga cookie among the cookies passed in context. Ensure GA4's browser tag has set it first.");
|
|
81
81
|
const session = sessionId(context.cookies, measurementId);
|
|
82
82
|
if (!session) report("no session cookie (_ga_<container>): Google will accept this event and it will not appear in any report");
|
|
83
83
|
const params = { ...data };
|
|
@@ -109,7 +109,7 @@ function ga4(credentials, config = {}) {
|
|
|
109
109
|
}
|
|
110
110
|
//#endregion
|
|
111
111
|
//#region src/server.ts
|
|
112
|
-
/** `name=value; name=value
|
|
112
|
+
/** `name=value; name=value`, the shape of a `Cookie` header. */
|
|
113
113
|
function parseCookies(cookies) {
|
|
114
114
|
if (!cookies) return {};
|
|
115
115
|
if (typeof cookies !== "string") return cookies;
|
|
@@ -130,7 +130,7 @@ function parseCookies(cookies) {
|
|
|
130
130
|
* one visitor to another visitor's conversion. Identity travels with the
|
|
131
131
|
* call, which is also what every conversion API expects.
|
|
132
132
|
*
|
|
133
|
-
* There is no `consent` call either
|
|
133
|
+
* There is no `consent` call either. Consent commands are properties of a
|
|
134
134
|
* vendor's on-page tag, and a server request has no tag. The one vendor that
|
|
135
135
|
* accepts consent server-side reads it from the context.
|
|
136
136
|
*
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","names":[],"sources":["../src/providers/ga4.server.ts","../src/server.ts"],"sourcesContent":["import type { ResolvedContext, ServerProvider } from '../server.js';\nimport type { ConsentState, EventBinding, EventData } from '../vocabulary.js';\n\nconst ENDPOINT = 'https://www.google-analytics.com/mp/collect';\n\n/** Credentials for the GA4 Measurement Protocol. */\nexport interface Ga4ServerCredentials {\n /** The `G-` id of the data stream. */\n measurementId: string;\n /** Created under Admin → Data Streams → Measurement Protocol. */\n apiSecret: string;\n}\n\n/** Optional configuration for the GA4 server provider. */\nexport interface Ga4ServerConfig {\n events?: Record<string, EventBinding<string>>;\n}\n\n/**\n * The visitor cookie holds `GA1.<depth>.<client_id>`, and the client id\n * itself contains a dot — so the identifier is the last two segments, which\n * is what survives Google's varying prefix depth.\n *\n * The value must be the cookie as the browser wrote it. A host passing a\n * parsed map passes cookie values, not values it took apart itself.\n */\nfunction visitorId(cookies: Record<string, string>): string | undefined {\n const raw = cookies._ga;\n if (!raw) return undefined;\n\n const parts = raw.split('.');\n if (parts.length < 4) return undefined;\n\n return parts.slice(-2).join('.');\n}\n\n/**\n * The per-container cookie carries the session, in one of two layouts.\n *\n * Verified against Google's live tag on 2026-07-31 rather than assumed:\n *\n * - `GS2.1.s1785511401$o1$g0$t1785511401$j60$l0$h0` — the current one. The\n * third dot-segment packs several fields joined by `$`, and the session is\n * the first of them, prefixed with `s`.\n * - `GS1.1.1785511401.1.1.…` — the older one, where the third dot-segment is\n * the session on its own.\n *\n * Reading the third segment blindly works for the old layout and returns the\n * whole `s…$o1$g0$…` blob for the new one — which the vendor would accept and\n * silently fail to match. Hence the two shapes, and hence `undefined` rather\n * than a guess when neither fits.\n */\nfunction sessionId(cookies: Record<string, string>, measurementId: string): string | undefined {\n // Scoped to *this* property's cookie. A page with two GA4 properties has two\n // `_ga_*` cookies, and taking whichever came first would attach another\n // property's session to our event — which Google accepts and never matches.\n const value = cookies[`_ga_${measurementId.replace(/^G-/, '')}`];\n const segment = value?.split('.')[2];\n if (!segment) return undefined;\n\n const packed = /^s(\\d+)/.exec(segment);\n if (packed) return packed[1];\n\n // The older layout: a bare session id, nothing packed around it.\n return /^\\d+$/.test(segment) ? segment : undefined;\n}\n\n/**\n * The Measurement Protocol documents exactly two consent fields, and spells\n * their values in upper case where the browser spells them in lower — one of\n * the small asymmetries an adapter absorbs so nobody else has to.\n */\nfunction consentBody(state: ConsentState | undefined): Record<string, string> | undefined {\n if (!state) return undefined;\n\n const body: Record<string, string> = {};\n if (state.ad_user_data) body.ad_user_data = state.ad_user_data.toUpperCase();\n if (state.ad_personalization) body.ad_personalization = state.ad_personalization.toUpperCase();\n\n return Object.keys(body).length > 0 ? body : undefined;\n}\n\n/**\n * Google Analytics 4 on the server, through the Measurement Protocol.\n *\n * @param credentials - The measurement id and API secret.\n * @param config - Rarely needed event name overrides.\n * @returns A provider for `createTracking` from `tracklane/server`.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/server';\n *\n * const { track } = createTracking({\n * providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret: process.env.GA4_SECRET! })],\n * });\n *\n * await track('purchase', order, { cookies: request.headers.get('cookie') });\n * ```\n */\nexport function ga4(\n credentials: Ga4ServerCredentials,\n config: Ga4ServerConfig = {},\n): ServerProvider {\n const { measurementId, apiSecret } = credentials;\n\n return {\n name: 'ga4',\n default: 'passthrough',\n ...(config.events ? { events: config.events } : {}),\n\n async track(\n name: string,\n data: EventData,\n context: ResolvedContext,\n report: (message: string) => void,\n ): Promise<void> {\n const clientId = visitorId(context.cookies);\n if (!clientId) {\n // Not withholding on policy grounds: the protocol requires this and\n // it cannot be invented. The endpoint answers success even to\n // invalid payloads, so sending anyway would look like it worked.\n // Naming the vendor's own field, so that searching Google's docs\n // for the error leads somewhere.\n throw new Error(\n 'ga4: cannot build client_id — no _ga cookie among the cookies passed in context',\n );\n }\n\n const session = sessionId(context.cookies, measurementId);\n if (!session) {\n report(\n 'no session cookie (_ga_<container>): Google will accept this event and it will not appear in any report',\n );\n }\n\n const params: Record<string, unknown> = { ...data };\n if (session) {\n params.session_id = session;\n // Google drops sessions with no engagement from its reports, so the\n // event would arrive and still be invisible without this.\n params.engagement_time_msec = 1;\n }\n\n // The dedup id maps nowhere: GA4 documents no browser-to-server\n // deduplication. `transaction_id` — business data — travels in params\n // and is the duplicate control this vendor does have.\n const body: Record<string, unknown> = {\n client_id: clientId,\n timestamp_micros: context.timestamp * 1000,\n events: [{ name, params }],\n };\n\n if (context.user?.userId !== undefined) body.user_id = context.user.userId;\n\n if (context.traits) {\n // Each property is an object with a `value` key here, where the\n // browser takes the bare value. Sending the bare value is rejected\n // by Google's validation endpoint and **accepted with 204 by the\n // collection endpoint**, which then drops it — verified on\n // 2026-07-31. One of several places where this vendor fails by\n // accepting.\n body.user_properties = Object.fromEntries(\n Object.entries(context.traits).map(([name, value]) => [name, { value }]),\n );\n }\n\n const consent = consentBody(context.consent);\n if (consent) body.consent = consent;\n\n // Credentials go in the query because the protocol offers no\n // alternative. Query strings land in proxy and APM logs; that\n // exposure is Google's design, not a choice made here.\n const url = `${ENDPOINT}?measurement_id=${encodeURIComponent(measurementId)}&api_secret=${encodeURIComponent(apiSecret)}`;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n });\n\n if (!response.ok) {\n // The status only. The event carries raw personal data, and no\n // library-owned surface re-emits it into a host's logs.\n throw new Error(`ga4: the Measurement Protocol answered ${response.status}`);\n }\n },\n };\n}\n","import { assertUniqueProviders, makeReporter, messageOf, resolveEvent } from './dispatch.js';\n\nexport {\n type Ga4ServerConfig,\n type Ga4ServerCredentials,\n ga4,\n} from './providers/ga4.server.js';\n\nimport type {\n ActionSource,\n ConsentState,\n EventBinding,\n EventData,\n ProviderDefault,\n TrackingError,\n UserData,\n} from './vocabulary.js';\n\n/**\n * Everything a conversion API needs beyond the event itself: who the person\n * is, when it happened, and where it was observed.\n *\n * None of this is \"what happened\", which is why it does not travel in the\n * event data — putting it there would leak identity into the pass-through\n * event params of the vendors that forward the payload as-is.\n */\nexport interface EventContext {\n user?: UserData;\n /**\n * The request's raw `Cookie` header, or an already-parsed map.\n *\n * These are cookies the vendors' own browser tags set — this library never\n * writes one. On the server each adapter reads the cookies belonging to\n * its own vendor: Meta forwards its value as-is, Google's holds several\n * things at once and has to be opened. That knowledge lives in the\n * adapters; the core only splits `name=value; name=value` into pairs.\n *\n * **It splits, it does not interpret.** Values are handed over exactly as\n * they appear, with no percent-decoding. The cookies these vendors set do\n * not need it; a host reading some other cookie should decode it itself.\n */\n cookies?: string | Record<string, string> | null;\n /** Ties this send to a browser hit. Never invented by the library. */\n dedupId?: string;\n /**\n * When the conversion actually happened. Defaults to now.\n *\n * A webhook arrives after the fact and LinkedIn rejects conversions older\n * than 90 days, so reporting \"now\" for everything would quietly distort\n * attribution.\n */\n timestamp?: number | Date;\n /** Overrides the factory default. Meta requires it on every event. */\n source?: ActionSource;\n url?: string;\n ip?: string;\n userAgent?: string;\n /** What is known *about* the person: GA4 user properties, PostHog `$set`. */\n traits?: Record<string, unknown>;\n /**\n * Travels inside the event, for the vendors that document such a field.\n * Today that is GA4's Measurement Protocol, and it reads two of the\n * signals; the others have no slot and ignore it.\n */\n consent?: ConsentState;\n}\n\n/** The context an adapter receives, with the ambiguity resolved. */\nexport interface ResolvedContext extends Omit<EventContext, 'cookies' | 'timestamp'> {\n cookies: Record<string, string>;\n /** Epoch milliseconds. Adapters convert to their vendor's spelling. */\n timestamp: number;\n}\n\n/**\n * A destination for server events.\n *\n * `track` may throw — on a non-2xx, or where a vendor documents a\n * precondition the event cannot meet. The dispatcher isolates and reports\n * it, so adapters stay honest and loud.\n *\n * `report` is for a caveat about a request that already succeeded. It is\n * never a throw: by then the send happened, and throwing would tell a host's\n * retry logic to resend a delivered conversion.\n */\nexport interface ServerProvider<Target = string> {\n name: string;\n default: ProviderDefault;\n events?: Record<string, EventBinding<Target>>;\n track(\n name: Target,\n data: EventData,\n context: ResolvedContext,\n report: (message: string) => void,\n ): Promise<void>;\n}\n\n/** Options accepted by {@link createTracking}. */\nexport interface ServerTrackingOptions {\n providers: readonly ServerProvider[];\n onError?: (error: TrackingError) => void;\n}\n\n/** What {@link createTracking} returns. */\nexport interface ServerTracking {\n /**\n * Sends one event to every configured conversion API.\n *\n * Resolves once every provider has settled and never rejects: one vendor\n * being down is not the caller's failure to handle. Awaiting is about the\n * request surviving a serverless runtime, not about delivery being\n * guaranteed.\n */\n track(name: string, data?: EventData, context?: EventContext): Promise<void>;\n}\n\n/** `name=value; name=value` — the shape of a `Cookie` header. */\nfunction parseCookies(\n cookies: string | Record<string, string> | null | undefined,\n): Record<string, string> {\n if (!cookies) return {};\n if (typeof cookies !== 'string') return cookies;\n\n const parsed: Record<string, string> = {};\n\n for (const pair of cookies.split(';')) {\n const separator = pair.indexOf('=');\n if (separator < 1) continue;\n\n const name = pair.slice(0, separator).trim();\n // Cookie values may contain `=`, so only the first one separates.\n if (name) parsed[name] = pair.slice(separator + 1).trim();\n }\n\n return parsed;\n}\n\n/**\n * Creates the server tracking surface.\n *\n * There is no `identify` here and nothing is held between calls: a server\n * instance is shared by every request, so a retained identity would attach\n * one visitor to another visitor's conversion. Identity travels with the\n * call, which is also what every conversion API expects.\n *\n * There is no `consent` call either — consent commands are properties of a\n * vendor's on-page tag, and a server request has no tag. The one vendor that\n * accepts consent server-side reads it from the context.\n *\n * @param options - The providers to send to, and an optional error channel.\n * @returns The `track` call.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/server';\n *\n * const { track } = createTracking({\n * providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret })],\n * });\n *\n * await track('purchase', order, { cookies: request.headers.get('cookie') });\n * ```\n */\nexport function createTracking(options: ServerTrackingOptions): ServerTracking {\n const { providers } = options;\n assertUniqueProviders(providers);\n\n const report = makeReporter(options.onError);\n\n return {\n async track(name, data = {}, context = {}) {\n const { cookies, timestamp, ...rest } = context;\n const resolved: ResolvedContext = {\n ...rest,\n cookies: parseCookies(cookies),\n timestamp: timestamp instanceof Date ? timestamp.getTime() : (timestamp ?? Date.now()),\n };\n\n const sends = providers.map(async (provider) => {\n const target = resolveEvent(provider, name);\n if (target === undefined) return;\n\n try {\n await provider.track(target, data, resolved, (message) => {\n report({ provider: provider.name, event: name, severity: 'warning', message });\n });\n } catch (cause) {\n report({\n provider: provider.name,\n event: name,\n severity: 'error',\n message: messageOf(cause),\n cause,\n });\n }\n });\n\n await Promise.all(sends);\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"server.js","names":[],"sources":["../src/providers/ga4.server.ts","../src/server.ts"],"sourcesContent":["import type { ResolvedContext, ServerProvider } from '../server.js';\nimport type { ConsentState, EventBinding, EventData } from '../vocabulary.js';\n\nconst ENDPOINT = 'https://www.google-analytics.com/mp/collect';\n\n/** Credentials for the GA4 Measurement Protocol. */\nexport interface Ga4ServerCredentials {\n /** The `G-` id of the data stream. */\n measurementId: string;\n /** Created under Admin → Data Streams → Measurement Protocol. */\n apiSecret: string;\n}\n\n/** Optional configuration for the GA4 server provider. */\nexport interface Ga4ServerConfig {\n events?: Record<string, EventBinding<string>>;\n}\n\n/**\n * The visitor cookie holds `GA1.<depth>.<client_id>`, and the client id\n * itself contains a dot, so the identifier is the last two segments, which\n * is what survives Google's varying prefix depth.\n *\n * The value must be the cookie as the browser wrote it. A host passing a\n * parsed map passes cookie values, not values it took apart itself.\n */\nfunction visitorId(cookies: Record<string, string>): string | undefined {\n const raw = cookies._ga;\n if (!raw) return undefined;\n\n const parts = raw.split('.');\n if (parts.length < 4) return undefined;\n\n return parts.slice(-2).join('.');\n}\n\n/**\n * The per-container cookie carries the session, in one of two layouts.\n *\n * Verified against Google's live tag on 2026-07-31 rather than assumed:\n *\n * - `GS2.1.s1785511401$o1$g0$t1785511401$j60$l0$h0`, the current one. The\n * third dot-segment packs several fields joined by `$`, and the session is\n * the first of them, prefixed with `s`.\n * - `GS1.1.1785511401.1.1.…`, the older one, where the third dot-segment is\n * the session on its own.\n *\n * Reading the third segment blindly works for the old layout and returns the\n * whole `s…$o1$g0$…` blob for the new one, which the vendor would accept and\n * silently fail to match. Hence the two shapes, and hence `undefined` rather\n * than a guess when neither fits.\n */\nfunction sessionId(cookies: Record<string, string>, measurementId: string): string | undefined {\n // Scoped to *this* property's cookie. A page with two GA4 properties has two\n // `_ga_*` cookies, and taking whichever came first would attach another\n // property's session to our event, which Google accepts and never matches.\n const value = cookies[`_ga_${measurementId.replace(/^G-/, '')}`];\n const segment = value?.split('.')[2];\n if (!segment) return undefined;\n\n const packed = /^s(\\d+)/.exec(segment);\n if (packed) return packed[1];\n\n // The older layout: a bare session id, nothing packed around it.\n return /^\\d+$/.test(segment) ? segment : undefined;\n}\n\n/**\n * The Measurement Protocol documents exactly two consent fields, and spells\n * their values in upper case where the browser spells them in lower, one of\n * the small asymmetries an adapter absorbs so nobody else has to.\n */\nfunction consentBody(state: ConsentState | undefined): Record<string, string> | undefined {\n if (!state) return undefined;\n\n const body: Record<string, string> = {};\n if (state.ad_user_data) body.ad_user_data = state.ad_user_data.toUpperCase();\n if (state.ad_personalization) body.ad_personalization = state.ad_personalization.toUpperCase();\n\n return Object.keys(body).length > 0 ? body : undefined;\n}\n\n/**\n * Google Analytics 4 on the server, through the Measurement Protocol.\n *\n * @param credentials - The measurement id and API secret.\n * @param config - Rarely needed event name overrides.\n * @returns A provider for `createTracking` from `tracklane/server`.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/server';\n *\n * const { track } = createTracking({\n * providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret: process.env.GA4_SECRET! })],\n * });\n *\n * await track('purchase', order, { cookies: request.headers.get('cookie') });\n * ```\n */\nexport function ga4(\n credentials: Ga4ServerCredentials,\n config: Ga4ServerConfig = {},\n): ServerProvider {\n const { measurementId, apiSecret } = credentials;\n\n return {\n name: 'ga4',\n default: 'passthrough',\n ...(config.events ? { events: config.events } : {}),\n\n async track(\n name: string,\n data: EventData,\n context: ResolvedContext,\n report: (message: string) => void,\n ): Promise<void> {\n const clientId = visitorId(context.cookies);\n if (!clientId) {\n // Not withholding on policy grounds: the protocol requires this and\n // it cannot be invented. The endpoint answers success even to\n // invalid payloads, so sending anyway would look like it worked.\n // Naming the vendor's own field, so that searching Google's docs\n // for the error leads somewhere.\n throw new Error(\n \"ga4: cannot build client_id, no _ga cookie among the cookies passed in context. Ensure GA4's browser tag has set it first.\",\n );\n }\n\n const session = sessionId(context.cookies, measurementId);\n if (!session) {\n report(\n 'no session cookie (_ga_<container>): Google will accept this event and it will not appear in any report',\n );\n }\n\n const params: Record<string, unknown> = { ...data };\n if (session) {\n params.session_id = session;\n // Google drops sessions with no engagement from its reports, so the\n // event would arrive and still be invisible without this.\n params.engagement_time_msec = 1;\n }\n\n // The dedup id maps nowhere: GA4 documents no browser-to-server\n // deduplication. `transaction_id` (business data) travels in params\n // and is the duplicate control this vendor does have.\n const body: Record<string, unknown> = {\n client_id: clientId,\n timestamp_micros: context.timestamp * 1000,\n events: [{ name, params }],\n };\n\n if (context.user?.userId !== undefined) body.user_id = context.user.userId;\n\n if (context.traits) {\n // Each property is an object with a `value` key here, where the\n // browser takes the bare value. Sending the bare value is rejected\n // by Google's validation endpoint and **accepted with 204 by the\n // collection endpoint**, which then drops it, verified on\n // 2026-07-31. One of several places where this vendor fails by\n // accepting.\n body.user_properties = Object.fromEntries(\n Object.entries(context.traits).map(([name, value]) => [name, { value }]),\n );\n }\n\n const consent = consentBody(context.consent);\n if (consent) body.consent = consent;\n\n // Credentials go in the query because the protocol offers no\n // alternative. Query strings land in proxy and APM logs; that\n // exposure is Google's design, not a choice made here.\n const url = `${ENDPOINT}?measurement_id=${encodeURIComponent(measurementId)}&api_secret=${encodeURIComponent(apiSecret)}`;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n });\n\n if (!response.ok) {\n // The status only. The event carries raw personal data, and no\n // library-owned surface re-emits it into a host's logs.\n throw new Error(`ga4: the Measurement Protocol answered ${response.status}`);\n }\n },\n };\n}\n","import { assertUniqueProviders, makeReporter, messageOf, resolveEvent } from './dispatch.js';\n\nexport {\n type Ga4ServerConfig,\n type Ga4ServerCredentials,\n ga4,\n} from './providers/ga4.server.js';\n\nimport type {\n ActionSource,\n ConsentState,\n EventBinding,\n EventData,\n ProviderDefault,\n TrackingError,\n UserData,\n} from './vocabulary.js';\n\n/**\n * Everything a conversion API needs beyond the event itself: who the person\n * is, when it happened, and where it was observed.\n *\n * None of this is \"what happened\", which is why it does not travel in the\n * event data: putting it there would leak identity into the pass-through\n * event params of the vendors that forward the payload as-is.\n */\nexport interface EventContext {\n /** The person's identity, the server-side counterpart to `identify()` on the browser. */\n user?: UserData;\n /**\n * The request's raw `Cookie` header, or an already-parsed map.\n *\n * These are cookies the vendors' own browser tags set. This library never\n * writes one. On the server each adapter reads the cookies belonging to\n * its own vendor: Meta forwards its value as-is, Google's holds several\n * things at once and has to be opened. That knowledge lives in the\n * adapters; the core only splits `name=value; name=value` into pairs.\n *\n * **It splits, it does not interpret.** Values are handed over exactly as\n * they appear, with no percent-decoding. The cookies these vendors set do\n * not need it; a host reading some other cookie should decode it itself.\n */\n cookies?: string | Record<string, string> | null;\n /** Ties this send to a browser hit. Never invented by the library. */\n dedupId?: string;\n /**\n * When the conversion actually happened. Defaults to now.\n *\n * A webhook arrives after the fact and LinkedIn rejects conversions older\n * than 90 days, so reporting \"now\" for everything would quietly distort\n * attribution.\n */\n timestamp?: number | Date;\n /** Overrides the factory default. Meta requires it on every event. */\n source?: ActionSource;\n /** The page the conversion happened on. Meta's Conversions API reads it as `event_source_url`. */\n url?: string;\n /** The visitor's IP. Meta's Conversions API reads it as one of the identifiers in `user_data`. */\n ip?: string;\n /** The visitor's user agent. Meta's Conversions API reads it alongside `ip`. */\n userAgent?: string;\n /** What is known *about* the person: GA4 user properties, PostHog `$set`. */\n traits?: Record<string, unknown>;\n /**\n * Travels inside the event, for the vendors that document such a field.\n * Today that is GA4's Measurement Protocol, and it reads two of the\n * signals; the others have no slot and ignore it.\n */\n consent?: ConsentState;\n}\n\n/** The context an adapter receives, with the ambiguity resolved. */\nexport interface ResolvedContext extends Omit<EventContext, 'cookies' | 'timestamp'> {\n /** The request's cookies, already split into a `name → value` map. */\n cookies: Record<string, string>;\n /** Epoch milliseconds. Adapters convert to their vendor's spelling. */\n timestamp: number;\n}\n\n/**\n * A destination for server events.\n *\n * `track` may throw, on a non-2xx or where a vendor documents a\n * precondition the event cannot meet. The dispatcher isolates and reports\n * it, so adapters stay honest and loud.\n *\n * `report` is for a caveat about a request that already succeeded. It does\n * not throw: by then the send happened, and throwing would tell a host's\n * retry logic to resend a delivered conversion.\n */\nexport interface ServerProvider<Target = string> {\n /** The vendor's fixed name. Registering one twice is an error. */\n name: string;\n /** What happens to a canonical event this vendor has no binding for. */\n default: ProviderDefault;\n /** How this vendor spells each canonical event. */\n events?: Record<string, EventBinding<Target>>;\n /** Sends one event to this vendor's conversion API. May throw, or call `report` for a caveat. */\n track(\n name: Target,\n data: EventData,\n context: ResolvedContext,\n report: (message: string) => void,\n ): Promise<void>;\n}\n\n/** Options accepted by {@link createTracking}. */\nexport interface ServerTrackingOptions {\n /** The vendors to send every event to. Registering the same `name` twice throws. */\n providers: readonly ServerProvider[];\n /**\n * The one diagnostic channel. Without it the library is silent, exactly as\n * a hand-written vendor call is.\n */\n onError?: (error: TrackingError) => void;\n}\n\n/** What {@link createTracking} returns. */\nexport interface ServerTracking {\n /**\n * Sends one event to every configured conversion API.\n *\n * Resolves once every provider has settled and never rejects: one vendor\n * being down is not the caller's failure to handle. Awaiting is about the\n * request surviving a serverless runtime, not about delivery being\n * guaranteed.\n */\n track(name: string, data?: EventData, context?: EventContext): Promise<void>;\n}\n\n/** `name=value; name=value`, the shape of a `Cookie` header. */\nfunction parseCookies(\n cookies: string | Record<string, string> | null | undefined,\n): Record<string, string> {\n if (!cookies) return {};\n if (typeof cookies !== 'string') return cookies;\n\n const parsed: Record<string, string> = {};\n\n for (const pair of cookies.split(';')) {\n const separator = pair.indexOf('=');\n if (separator < 1) continue;\n\n const name = pair.slice(0, separator).trim();\n // Cookie values may contain `=`, so only the first one separates.\n if (name) parsed[name] = pair.slice(separator + 1).trim();\n }\n\n return parsed;\n}\n\n/**\n * Creates the server tracking surface.\n *\n * There is no `identify` here and nothing is held between calls: a server\n * instance is shared by every request, so a retained identity would attach\n * one visitor to another visitor's conversion. Identity travels with the\n * call, which is also what every conversion API expects.\n *\n * There is no `consent` call either. Consent commands are properties of a\n * vendor's on-page tag, and a server request has no tag. The one vendor that\n * accepts consent server-side reads it from the context.\n *\n * @param options - The providers to send to, and an optional error channel.\n * @returns The `track` call.\n *\n * @example\n * ```ts\n * import { createTracking, ga4 } from 'tracklane/server';\n *\n * const { track } = createTracking({\n * providers: [ga4({ measurementId: 'G-XXXXXXX', apiSecret })],\n * });\n *\n * await track('purchase', order, { cookies: request.headers.get('cookie') });\n * ```\n */\nexport function createTracking(options: ServerTrackingOptions): ServerTracking {\n const { providers } = options;\n assertUniqueProviders(providers);\n\n const report = makeReporter(options.onError);\n\n return {\n async track(name, data = {}, context = {}) {\n const { cookies, timestamp, ...rest } = context;\n const resolved: ResolvedContext = {\n ...rest,\n cookies: parseCookies(cookies),\n timestamp: timestamp instanceof Date ? timestamp.getTime() : (timestamp ?? Date.now()),\n };\n\n const sends = providers.map(async (provider) => {\n const target = resolveEvent(provider, name);\n if (target === undefined) return;\n\n try {\n await provider.track(target, data, resolved, (message) => {\n report({ provider: provider.name, event: name, severity: 'warning', message });\n });\n } catch (cause) {\n report({\n provider: provider.name,\n event: name,\n severity: 'error',\n message: messageOf(cause),\n cause,\n });\n }\n });\n\n await Promise.all(sends);\n },\n };\n}\n"],"mappings":";;AAGA,MAAM,WAAW;;;;;;;;;AAuBjB,SAAS,UAAU,SAAqD;CACtE,MAAM,MAAM,QAAQ;CACpB,IAAI,CAAC,KAAK,OAAO,KAAA;CAEjB,MAAM,QAAQ,IAAI,MAAM,GAAG;CAC3B,IAAI,MAAM,SAAS,GAAG,OAAO,KAAA;CAE7B,OAAO,MAAM,MAAM,EAAE,CAAC,CAAC,KAAK,GAAG;AACjC;;;;;;;;;;;;;;;;;AAkBA,SAAS,UAAU,SAAiC,eAA2C;CAK7F,MAAM,UADQ,QAAQ,OAAO,cAAc,QAAQ,OAAO,EAAE,IACvC,EAAE,MAAM,GAAG,CAAC,CAAC;CAClC,IAAI,CAAC,SAAS,OAAO,KAAA;CAErB,MAAM,SAAS,UAAU,KAAK,OAAO;CACrC,IAAI,QAAQ,OAAO,OAAO;CAG1B,OAAO,QAAQ,KAAK,OAAO,IAAI,UAAU,KAAA;AAC3C;;;;;;AAOA,SAAS,YAAY,OAAqE;CACxF,IAAI,CAAC,OAAO,OAAO,KAAA;CAEnB,MAAM,OAA+B,CAAC;CACtC,IAAI,MAAM,cAAc,KAAK,eAAe,MAAM,aAAa,YAAY;CAC3E,IAAI,MAAM,oBAAoB,KAAK,qBAAqB,MAAM,mBAAmB,YAAY;CAE7F,OAAO,OAAO,KAAK,IAAI,CAAC,CAAC,SAAS,IAAI,OAAO,KAAA;AAC/C;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,IACd,aACA,SAA0B,CAAC,GACX;CAChB,MAAM,EAAE,eAAe,cAAc;CAErC,OAAO;EACL,MAAM;EACN,SAAS;EACT,GAAI,OAAO,SAAS,EAAE,QAAQ,OAAO,OAAO,IAAI,CAAC;EAEjD,MAAM,MACJ,MACA,MACA,SACA,QACe;GACf,MAAM,WAAW,UAAU,QAAQ,OAAO;GAC1C,IAAI,CAAC,UAMH,MAAM,IAAI,MACR,4HACF;GAGF,MAAM,UAAU,UAAU,QAAQ,SAAS,aAAa;GACxD,IAAI,CAAC,SACH,OACE,yGACF;GAGF,MAAM,SAAkC,EAAE,GAAG,KAAK;GAClD,IAAI,SAAS;IACX,OAAO,aAAa;IAGpB,OAAO,uBAAuB;GAChC;GAKA,MAAM,OAAgC;IACpC,WAAW;IACX,kBAAkB,QAAQ,YAAY;IACtC,QAAQ,CAAC;KAAE;KAAM;IAAO,CAAC;GAC3B;GAEA,IAAI,QAAQ,MAAM,WAAW,KAAA,GAAW,KAAK,UAAU,QAAQ,KAAK;GAEpE,IAAI,QAAQ,QAOV,KAAK,kBAAkB,OAAO,YAC5B,OAAO,QAAQ,QAAQ,MAAM,CAAC,CAAC,KAAK,CAAC,MAAM,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CACzE;GAGF,MAAM,UAAU,YAAY,QAAQ,OAAO;GAC3C,IAAI,SAAS,KAAK,UAAU;GAK5B,MAAM,MAAM,GAAG,SAAS,kBAAkB,mBAAmB,aAAa,EAAE,cAAc,mBAAmB,SAAS;GAEtH,MAAM,WAAW,MAAM,MAAM,KAAK;IAChC,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;IAC9C,MAAM,KAAK,UAAU,IAAI;GAC3B,CAAC;GAED,IAAI,CAAC,SAAS,IAGZ,MAAM,IAAI,MAAM,0CAA0C,SAAS,QAAQ;EAE/E;CACF;AACF;;;;ACzDA,SAAS,aACP,SACwB;CACxB,IAAI,CAAC,SAAS,OAAO,CAAC;CACtB,IAAI,OAAO,YAAY,UAAU,OAAO;CAExC,MAAM,SAAiC,CAAC;CAExC,KAAK,MAAM,QAAQ,QAAQ,MAAM,GAAG,GAAG;EACrC,MAAM,YAAY,KAAK,QAAQ,GAAG;EAClC,IAAI,YAAY,GAAG;EAEnB,MAAM,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC,CAAC,KAAK;EAE3C,IAAI,MAAM,OAAO,QAAQ,KAAK,MAAM,YAAY,CAAC,CAAC,CAAC,KAAK;CAC1D;CAEA,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,SAAgB,eAAe,SAAgD;CAC7E,MAAM,EAAE,cAAc;CACtB,sBAAsB,SAAS;CAE/B,MAAM,SAAS,aAAa,QAAQ,OAAO;CAE3C,OAAO,EACL,MAAM,MAAM,MAAM,OAAO,CAAC,GAAG,UAAU,CAAC,GAAG;EACzC,MAAM,EAAE,SAAS,WAAW,GAAG,SAAS;EACxC,MAAM,WAA4B;GAChC,GAAG;GACH,SAAS,aAAa,OAAO;GAC7B,WAAW,qBAAqB,OAAO,UAAU,QAAQ,IAAK,aAAa,KAAK,IAAI;EACtF;EAEA,MAAM,QAAQ,UAAU,IAAI,OAAO,aAAa;GAC9C,MAAM,SAAS,aAAa,UAAU,IAAI;GAC1C,IAAI,WAAW,KAAA,GAAW;GAE1B,IAAI;IACF,MAAM,SAAS,MAAM,QAAQ,MAAM,WAAW,YAAY;KACxD,OAAO;MAAE,UAAU,SAAS;MAAM,OAAO;MAAM,UAAU;MAAW;KAAQ,CAAC;IAC/E,CAAC;GACH,SAAS,OAAO;IACd,OAAO;KACL,UAAU,SAAS;KACnB,OAAO;KACP,UAAU;KACV,SAAS,UAAU,KAAK;KACxB;IACF,CAAC;GACH;EACF,CAAC;EAED,MAAM,QAAQ,IAAI,KAAK;CACzB,EACF;AACF"}
|
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* The vocabulary shared by the browser and server libraries.
|
|
4
4
|
*
|
|
5
|
-
* These two libraries never talk to each other
|
|
6
|
-
* instance in common. What they share is this: the names of
|
|
7
|
-
* shape of the data that travels with them, so that
|
|
8
|
-
* same thing on both sides.
|
|
5
|
+
* These two libraries never talk to each other. They share no runtime, no
|
|
6
|
+
* state, no instance in common. What they share is this: the names of
|
|
7
|
+
* events and the shape of the data that travels with them, so that
|
|
8
|
+
* `purchase` means the same thing on both sides. This is the only overlap
|
|
9
|
+
* that exists on purpose.
|
|
9
10
|
*/
|
|
10
11
|
/** One item inside an ecommerce event, spelled the way GA4 spells it. */
|
|
11
12
|
interface EventItem {
|
|
13
|
+
/** The SKU or catalogue id for this line. */
|
|
12
14
|
item_id?: string;
|
|
15
|
+
/** The product name for this line, shown in reports. */
|
|
13
16
|
item_name?: string;
|
|
17
|
+
/** The unit price for this line, before `quantity`. */
|
|
14
18
|
price?: number;
|
|
19
|
+
/** How many units of this line. */
|
|
15
20
|
quantity?: number;
|
|
16
21
|
[key: string]: unknown;
|
|
17
22
|
}
|
|
@@ -24,9 +29,13 @@ interface EventItem {
|
|
|
24
29
|
* library the arbiter of what may be measured.
|
|
25
30
|
*/
|
|
26
31
|
interface EventData {
|
|
32
|
+
/** The order id. GA4 needs it, together with `items`, to record a purchase. */
|
|
27
33
|
transaction_id?: string;
|
|
34
|
+
/** The monetary amount, paired with `currency`. */
|
|
28
35
|
value?: number;
|
|
36
|
+
/** An ISO 4217 code. GA4 requires it whenever `value` is set. */
|
|
29
37
|
currency?: string;
|
|
38
|
+
/** The cart or order lines, required by GA4 alongside `transaction_id` for a purchase. */
|
|
30
39
|
items?: EventItem[];
|
|
31
40
|
[key: string]: unknown;
|
|
32
41
|
}
|
|
@@ -34,7 +43,7 @@ interface EventData {
|
|
|
34
43
|
* Who the person is.
|
|
35
44
|
*
|
|
36
45
|
* One identifier for the person, which every vendor receives under its own
|
|
37
|
-
* name. Values are always raw
|
|
46
|
+
* name. Values are always raw: hashing and normalisation are field formats
|
|
38
47
|
* each vendor defines, applied inside the adapter that owns the field.
|
|
39
48
|
*
|
|
40
49
|
* Everything else that identifies someone arrives through cookies on the
|
|
@@ -43,9 +52,13 @@ interface EventData {
|
|
|
43
52
|
interface UserData {
|
|
44
53
|
/** One id for the person, translated per vendor. */
|
|
45
54
|
userId?: string;
|
|
55
|
+
/** Raw and unhashed. Adapters that need it hashed (Meta, LinkedIn) do that themselves. */
|
|
46
56
|
email?: string;
|
|
57
|
+
/** Raw, in whatever format the host has it. An adapter normalises it if its vendor needs to. */
|
|
47
58
|
phone?: string;
|
|
59
|
+
/** Raw, read only by vendors whose identity match uses names, such as Meta. */
|
|
48
60
|
firstName?: string;
|
|
61
|
+
/** Raw, read only by vendors whose identity match uses names, such as Meta. */
|
|
49
62
|
lastName?: string;
|
|
50
63
|
/** From the URL rather than a cookie, which is why it is named here. */
|
|
51
64
|
twclid?: string;
|
|
@@ -56,23 +69,27 @@ interface UserData {
|
|
|
56
69
|
* Google's is the most granular surface any of the five vendors exposes, so
|
|
57
70
|
* every other vendor receives a *collapse* of it. Carrying the finest
|
|
58
71
|
* resolution and reducing per vendor means a projection never claims more
|
|
59
|
-
* than the host declared
|
|
72
|
+
* than the host declared. Expanding a coarse answer into fine signals would
|
|
60
73
|
* invent information, and this library never does that.
|
|
61
74
|
*
|
|
62
|
-
* An absent signal produces no key
|
|
63
|
-
*
|
|
75
|
+
* An absent signal produces no key. Treating silence as a denial would put
|
|
76
|
+
* words in the visitor's mouth, just as treating it as a grant would.
|
|
64
77
|
*/
|
|
65
78
|
interface ConsentState {
|
|
79
|
+
/** Governs Google's ad cookies. Browser only, since GA4's server-side API has no slot for it. */
|
|
66
80
|
ad_storage?: "granted" | "denied";
|
|
81
|
+
/** Governs Google's analytics cookies. Browser only, since GA4's server-side API has no slot. */
|
|
67
82
|
analytics_storage?: "granted" | "denied";
|
|
83
|
+
/** Whether user data may be sent for ads. GA4's server-side API reads this one, uppercased. */
|
|
68
84
|
ad_user_data?: "granted" | "denied";
|
|
85
|
+
/** Whether ads may be personalised. GA4's server-side API reads this one too, uppercased. */
|
|
69
86
|
ad_personalization?: "granted" | "denied";
|
|
70
87
|
}
|
|
71
88
|
/**
|
|
72
89
|
* Where a conversion happened.
|
|
73
90
|
*
|
|
74
|
-
* Meta's vocabulary, adopted verbatim
|
|
75
|
-
* that documents the concept
|
|
91
|
+
* Meta's vocabulary, adopted verbatim, because it is the only vendor of the
|
|
92
|
+
* five that documents the concept and requires the field on every server
|
|
76
93
|
* event. Inventing a taxonomy and translating it would be both incomplete
|
|
77
94
|
* for Meta and meaningless to everyone else.
|
|
78
95
|
*/
|
|
@@ -96,12 +113,17 @@ type ProviderDefault = "passthrough" | "ignore";
|
|
|
96
113
|
* design, and no library-owned surface re-emits it into a host's logs.
|
|
97
114
|
*/
|
|
98
115
|
interface TrackingError {
|
|
116
|
+
/** Which vendor produced this. It matches the `name` on the provider that registered it. */
|
|
99
117
|
provider: string;
|
|
118
|
+
/** The canonical event name being sent when this happened. */
|
|
100
119
|
event: string;
|
|
120
|
+
/** `'error'` when the event did not arrive; `'warning'` when it arrived but is worth a look. */
|
|
101
121
|
severity: "error" | "warning";
|
|
122
|
+
/** What happened, in one line, excluding the payload: event data never appears here. */
|
|
102
123
|
message: string;
|
|
124
|
+
/** The original thrown value, where one exists, for a host that wants more than `message`. */
|
|
103
125
|
cause?: unknown;
|
|
104
126
|
}
|
|
105
127
|
//#endregion
|
|
106
128
|
export { EventItem as a, UserData as c, EventData as i, ConsentState as n, ProviderDefault as o, EventBinding as r, TrackingError as s, ActionSource as t };
|
|
107
|
-
//# sourceMappingURL=vocabulary-
|
|
129
|
+
//# sourceMappingURL=vocabulary-fzM9ZuE1.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tracklane",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "One interface between your application and every tool that receives events
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "One interface between your application and every tool that receives events, for the browser and server. GA4 today; Meta, LinkedIn, PostHog and X next.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"analytics",
|
|
7
7
|
"tracking",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"author": "Bruno Bertolini <dev@brunobertolini.com>",
|
|
18
|
-
"homepage": "https://
|
|
18
|
+
"homepage": "https://tracklane.codar.me",
|
|
19
19
|
"repository": {
|
|
20
20
|
"type": "git",
|
|
21
21
|
"url": "git+https://github.com/brunobertolini/tracklane.git",
|
|
@@ -51,6 +51,15 @@
|
|
|
51
51
|
"access": "public",
|
|
52
52
|
"provenance": true
|
|
53
53
|
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@arethetypeswrong/cli": "0.18.5",
|
|
56
|
+
"@types/node": "^26.1.2",
|
|
57
|
+
"@vitest/coverage-v8": "4.1.10",
|
|
58
|
+
"publint": "0.3.22",
|
|
59
|
+
"tsdown": "0.22.14",
|
|
60
|
+
"typescript": "^6.0.3",
|
|
61
|
+
"vitest": "4.1.10"
|
|
62
|
+
},
|
|
54
63
|
"scripts": {
|
|
55
64
|
"build": "tsdown",
|
|
56
65
|
"dev": "tsdown --watch",
|
|
@@ -58,14 +67,5 @@
|
|
|
58
67
|
"test:coverage": "vitest run --coverage",
|
|
59
68
|
"typecheck": "tsc --noEmit",
|
|
60
69
|
"package:check": "publint --strict && attw --pack . --profile esm-only"
|
|
61
|
-
},
|
|
62
|
-
"devDependencies": {
|
|
63
|
-
"@arethetypeswrong/cli": "0.18.5",
|
|
64
|
-
"@types/node": "catalog:",
|
|
65
|
-
"@vitest/coverage-v8": "4.1.10",
|
|
66
|
-
"publint": "0.3.22",
|
|
67
|
-
"tsdown": "0.22.14",
|
|
68
|
-
"typescript": "catalog:",
|
|
69
|
-
"vitest": "4.1.10"
|
|
70
70
|
}
|
|
71
|
-
}
|
|
71
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dispatch-DK2GBnkt.js","names":[],"sources":["../src/dispatch.ts"],"sourcesContent":["import type { EventBinding, ProviderDefault, TrackingError } from './vocabulary.js';\n\n/** The part of a provider both libraries resolve event names against. */\ninterface Resolvable<Target> {\n name: string;\n default: ProviderDefault;\n events?: Record<string, EventBinding<Target>>;\n}\n\n/**\n * Registering one vendor twice is a hard error rather than a merge: there is\n * nothing a second registration could say that the first cannot, and\n * accepting both would double every event to that vendor — a defect that\n * surfaces weeks later as inflated conversions.\n */\nexport function assertUniqueProviders(providers: readonly { name: string }[]): void {\n const seen = new Set<string>();\n\n for (const provider of providers) {\n if (seen.has(provider.name)) {\n throw new Error(\n `tracklane: provider \"${provider.name}\" is registered twice. One configuration per vendor.`,\n );\n }\n seen.add(provider.name);\n }\n}\n\n/**\n * Resolves how a vendor spells a canonical event, or `undefined` when\n * nothing should be sent.\n *\n * Not sending is a legitimate outcome rather than a failure: `null` is the\n * host saying \"never send this event here\", and `ignore` is a vendor whose\n * event identity is minted per account in its own dashboard, so there is no\n * name that could possibly be guessed.\n */\nexport function resolveEvent<Target>(\n provider: Resolvable<Target>,\n name: string,\n): Target | undefined {\n const events = provider.events;\n // `hasOwn`, not a plain lookup: every object inherits `constructor` and\n // `toString`, so an event named either would otherwise resolve to a\n // function off the prototype chain.\n const binding = events && Object.hasOwn(events, name) ? events[name] : undefined;\n\n if (binding === null) return undefined;\n if (binding !== undefined) return binding;\n if (provider.default === 'ignore') return undefined;\n\n return name as Target;\n}\n\n/**\n * Builds the reporting channel. A host that passes no `onError` gets\n * silence, which is what a hand-written vendor call gives it.\n */\nexport function makeReporter(\n onError: ((error: TrackingError) => void) | undefined,\n): (error: TrackingError) => void {\n if (!onError) return () => {};\n\n return (error) => {\n // A throwing reporter must not take the dispatch down with it, or a\n // host's broken logger becomes a broken checkout.\n try {\n onError(error);\n } catch {\n /* the reporter is the last line; there is nowhere left to report to */\n }\n };\n}\n\n/** Normalises whatever a provider threw into a reportable message. */\nexport function messageOf(cause: unknown): string {\n return cause instanceof Error ? cause.message : String(cause);\n}\n"],"mappings":";;;;;;;AAeA,SAAgB,sBAAsB,WAA8C;CAClF,MAAM,uBAAO,IAAI,IAAY;CAE7B,KAAK,MAAM,YAAY,WAAW;EAChC,IAAI,KAAK,IAAI,SAAS,IAAI,GACxB,MAAM,IAAI,MACR,wBAAwB,SAAS,KAAK,qDACxC;EAEF,KAAK,IAAI,SAAS,IAAI;CACxB;AACF;;;;;;;;;;AAWA,SAAgB,aACd,UACA,MACoB;CACpB,MAAM,SAAS,SAAS;CAIxB,MAAM,UAAU,UAAU,OAAO,OAAO,QAAQ,IAAI,IAAI,OAAO,QAAQ,KAAA;CAEvE,IAAI,YAAY,MAAM,OAAO,KAAA;CAC7B,IAAI,YAAY,KAAA,GAAW,OAAO;CAClC,IAAI,SAAS,YAAY,UAAU,OAAO,KAAA;CAE1C,OAAO;AACT;;;;;AAMA,SAAgB,aACd,SACgC;CAChC,IAAI,CAAC,SAAS,aAAa,CAAC;CAE5B,QAAQ,UAAU;EAGhB,IAAI;GACF,QAAQ,KAAK;EACf,QAAQ,CAER;CACF;AACF;;AAGA,SAAgB,UAAU,OAAwB;CAChD,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC9D"}
|