@shware/analytics 8.2.0 → 8.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +78 -2
  2. package/dist/click-id/index.cjs +57 -3
  3. package/dist/click-id/index.cjs.map +1 -1
  4. package/dist/click-id/index.d.cts +16 -1
  5. package/dist/click-id/index.d.cts.map +1 -1
  6. package/dist/click-id/index.d.mts +16 -1
  7. package/dist/click-id/index.d.mts.map +1 -1
  8. package/dist/click-id/index.mjs +54 -4
  9. package/dist/click-id/index.mjs.map +1 -1
  10. package/dist/hooks/use-web-analytics.cjs +11 -5
  11. package/dist/hooks/use-web-analytics.cjs.map +1 -1
  12. package/dist/hooks/use-web-analytics.d.cts +4 -1
  13. package/dist/hooks/use-web-analytics.d.cts.map +1 -1
  14. package/dist/hooks/use-web-analytics.d.mts +4 -1
  15. package/dist/hooks/use-web-analytics.d.mts.map +1 -1
  16. package/dist/hooks/use-web-analytics.mjs +11 -5
  17. package/dist/hooks/use-web-analytics.mjs.map +1 -1
  18. package/dist/next/index.cjs +38 -2
  19. package/dist/next/index.cjs.map +1 -1
  20. package/dist/next/index.d.cts +3 -1
  21. package/dist/next/index.d.cts.map +1 -1
  22. package/dist/next/index.d.mts +3 -1
  23. package/dist/next/index.d.mts.map +1 -1
  24. package/dist/next/index.mjs +39 -3
  25. package/dist/next/index.mjs.map +1 -1
  26. package/dist/react-router/index.cjs +25 -3
  27. package/dist/react-router/index.cjs.map +1 -1
  28. package/dist/react-router/index.d.cts +3 -1
  29. package/dist/react-router/index.d.cts.map +1 -1
  30. package/dist/react-router/index.d.mts +3 -1
  31. package/dist/react-router/index.d.mts.map +1 -1
  32. package/dist/react-router/index.mjs +25 -3
  33. package/dist/react-router/index.mjs.map +1 -1
  34. package/dist/schema/index.cjs +1 -0
  35. package/dist/schema/index.cjs.map +1 -1
  36. package/dist/schema/index.d.cts +4 -0
  37. package/dist/schema/index.d.cts.map +1 -1
  38. package/dist/schema/index.d.mts +4 -0
  39. package/dist/schema/index.d.mts.map +1 -1
  40. package/dist/schema/index.mjs +1 -0
  41. package/dist/schema/index.mjs.map +1 -1
  42. package/dist/server/google-data-manager.cjs +2 -0
  43. package/dist/server/google-data-manager.cjs.map +1 -1
  44. package/dist/server/google-data-manager.d.cts.map +1 -1
  45. package/dist/server/google-data-manager.d.mts.map +1 -1
  46. package/dist/server/google-data-manager.mjs +2 -0
  47. package/dist/server/google-data-manager.mjs.map +1 -1
  48. package/dist/server/index.cjs +8 -0
  49. package/dist/server/index.d.cts +5 -4
  50. package/dist/server/index.d.mts +5 -4
  51. package/dist/server/index.mjs +5 -4
  52. package/dist/server/microsoft-conversions-api.cjs +171 -0
  53. package/dist/server/microsoft-conversions-api.cjs.map +1 -0
  54. package/dist/server/microsoft-conversions-api.d.cts +124 -0
  55. package/dist/server/microsoft-conversions-api.d.cts.map +1 -0
  56. package/dist/server/microsoft-conversions-api.d.mts +124 -0
  57. package/dist/server/microsoft-conversions-api.d.mts.map +1 -0
  58. package/dist/server/microsoft-conversions-api.mjs +168 -0
  59. package/dist/server/microsoft-conversions-api.mjs.map +1 -0
  60. package/dist/tanstack/analytics.cjs +25 -3
  61. package/dist/tanstack/analytics.cjs.map +1 -1
  62. package/dist/tanstack/analytics.d.cts +3 -1
  63. package/dist/tanstack/analytics.d.cts.map +1 -1
  64. package/dist/tanstack/analytics.d.mts +3 -1
  65. package/dist/tanstack/analytics.d.mts.map +1 -1
  66. package/dist/tanstack/analytics.mjs +25 -3
  67. package/dist/tanstack/analytics.mjs.map +1 -1
  68. package/dist/tanstack/middleware.cjs +2 -1
  69. package/dist/tanstack/middleware.cjs.map +1 -1
  70. package/dist/tanstack/middleware.d.cts +2 -1
  71. package/dist/tanstack/middleware.d.cts.map +1 -1
  72. package/dist/tanstack/middleware.d.mts +2 -1
  73. package/dist/tanstack/middleware.d.mts.map +1 -1
  74. package/dist/tanstack/middleware.mjs +2 -1
  75. package/dist/tanstack/middleware.mjs.map +1 -1
  76. package/dist/third-parties/index.cjs +5 -0
  77. package/dist/third-parties/index.d.cts +3 -1
  78. package/dist/third-parties/index.d.mts +3 -1
  79. package/dist/third-parties/index.mjs +2 -1
  80. package/dist/third-parties/microsoft-uet.cjs +119 -0
  81. package/dist/third-parties/microsoft-uet.cjs.map +1 -0
  82. package/dist/third-parties/microsoft-uet.d.cts +70 -0
  83. package/dist/third-parties/microsoft-uet.d.cts.map +1 -0
  84. package/dist/third-parties/microsoft-uet.d.mts +70 -0
  85. package/dist/third-parties/microsoft-uet.d.mts.map +1 -0
  86. package/dist/third-parties/microsoft-uet.mjs +115 -0
  87. package/dist/third-parties/microsoft-uet.mjs.map +1 -0
  88. package/dist/third-parties/openai-pixel.cjs +2 -7
  89. package/dist/third-parties/openai-pixel.cjs.map +1 -1
  90. package/dist/third-parties/openai-pixel.d.cts.map +1 -1
  91. package/dist/third-parties/openai-pixel.d.mts.map +1 -1
  92. package/dist/third-parties/openai-pixel.mjs +1 -6
  93. package/dist/third-parties/openai-pixel.mjs.map +1 -1
  94. package/dist/track/types.d.cts +7 -0
  95. package/dist/track/types.d.cts.map +1 -1
  96. package/dist/track/types.d.mts +7 -0
  97. package/dist/track/types.d.mts.map +1 -1
  98. package/dist/track/uetq.cjs +80 -0
  99. package/dist/track/uetq.cjs.map +1 -0
  100. package/dist/track/uetq.d.cts +111 -0
  101. package/dist/track/uetq.d.cts.map +1 -0
  102. package/dist/track/uetq.d.mts +111 -0
  103. package/dist/track/uetq.d.mts.map +1 -0
  104. package/dist/track/uetq.mjs +78 -0
  105. package/dist/track/uetq.mjs.map +1 -0
  106. package/dist/utils/sha256.cjs +19 -0
  107. package/dist/utils/sha256.cjs.map +1 -0
  108. package/dist/utils/sha256.d.cts +13 -0
  109. package/dist/utils/sha256.d.cts.map +1 -0
  110. package/dist/utils/sha256.d.mts +13 -0
  111. package/dist/utils/sha256.d.mts.map +1 -0
  112. package/dist/utils/sha256.mjs +18 -0
  113. package/dist/utils/sha256.mjs.map +1 -0
  114. package/dist/web/index.cjs +26 -2
  115. package/dist/web/index.cjs.map +1 -1
  116. package/dist/web/index.d.cts.map +1 -1
  117. package/dist/web/index.d.mts.map +1 -1
  118. package/dist/web/index.mjs +27 -3
  119. package/dist/web/index.mjs.map +1 -1
  120. package/dist/web/page-key.cjs +53 -0
  121. package/dist/web/page-key.cjs.map +1 -0
  122. package/dist/web/page-key.d.cts +24 -0
  123. package/dist/web/page-key.d.cts.map +1 -0
  124. package/dist/web/page-key.d.mts +24 -0
  125. package/dist/web/page-key.d.mts.map +1 -0
  126. package/dist/web/page-key.mjs +52 -0
  127. package/dist/web/page-key.mjs.map +1 -0
  128. package/package.json +1 -1
package/README.md CHANGED
@@ -9,8 +9,8 @@ Workers), but calling `track()` there shares one session and one visitor across
9
9
  isolate serves.
10
10
 
11
11
  Server-side conversions go the other way: store the client's events, then hand them to
12
- `@shware/analytics/server`, which takes a `TrackEvent` and forwards it to the Meta, Reddit, OpenAI
13
- and LinkedIn conversions APIs.
12
+ `@shware/analytics/server`, which takes a `TrackEvent` and forwards it to the Meta, Reddit, OpenAI,
13
+ LinkedIn, Google Ads and Microsoft Advertising conversions APIs.
14
14
 
15
15
  ## Config
16
16
 
@@ -46,6 +46,15 @@ function Button() {
46
46
  }
47
47
  ```
48
48
 
49
+ ## Page views
50
+
51
+ `page_view` fires on a path or query change, never on a hash change — GA4's enhanced measurement,
52
+ Next.js's `usePathname` + `useSearchParams` pattern and PostHog's `history_change` all draw the
53
+ line there (`?page=2` is another page to a funnel, `#faq` is not). Tracking parameters (`utm_*`
54
+ and the ad click ids) are ignored when comparing, so a landing page that cleans them out of its
55
+ URL with `replaceState` does not count itself twice. `page_load_id` rotates on exactly the same
56
+ changes. The framework `Analytics` components wire this up.
57
+
49
58
  ## Backend API
50
59
 
51
60
  - /analytics/tracks: track events
@@ -124,4 +133,71 @@ We strongly recommend using the Reddit Pixel and Conversions API (CAPI) together
124
133
  If we receive an Insight Tag event and a Conversions API event from the same account with the same eventId, we discard the Conversions API event and count only the Insight Tag event in campaign reporting.
125
134
  ``
126
135
 
136
+ ### Microsoft Advertising (Bing Ads)
137
+
138
+ Microsoft recommends the UET tag and the Conversions API (CAPI) together. The two deduplicate on
139
+ (`tagId`, `eventName`, `eventId`): the browser sends the internal event name as the UET action with
140
+ the event id as `event_id`, the server sends the same name as `eventName` with the same id.
141
+
142
+ Browser — pass the tag id to the framework `Analytics` component (`<Analytics uetTagId="97267979" />`
143
+ from `@shware/analytics/tanstack`, `/next` or `/react-router`), which inlines Microsoft's snippet
144
+ with `enableAutoSpaTracking: true`: the tag owns page loads, `sendUETEvent` drops `page_view` and
145
+ forwards everything else. Then:
146
+
147
+ ```ts
148
+ import {
149
+ sendUETEvent,
150
+ setUETUser,
151
+ setUETConsent,
152
+ sendUETIdSync,
153
+ } from '@shware/analytics/third-parties';
154
+
155
+ setupAnalytics({
156
+ thirdPartyTrackers: [sendUETEvent],
157
+ // Enhanced conversions: the tag hashes the email/phone itself before they leave the page.
158
+ thirdPartyUserSetters: [setUETUser()],
159
+ });
160
+ // Consent mode (EEA/UK/CH): push `default` before the tag loads, `update` on the visitor's choice.
161
+ setUETConsent('default', { ad_storage: 'denied', wait_for_update: 2000 });
162
+ // ID Sync, once per session: ties the visitor id CAPI sends as `anonymousId` to Microsoft's ids.
163
+ // Required for remarketing built from CAPI events, recommended for measurement. `VID` must equal
164
+ // `anonymousId` and `UID` must equal `externalId`: pass the SDK visitor id and the raw user id —
165
+ // the pixel hashes the user id with the same SHA-256 the server sender uses.
166
+ sendUETIdSync({ customerId: 255004870, visitorId: (await getVisitor()).id, userId: user.id });
167
+ ```
168
+
169
+ Server — the token comes from the UET tag's "Use Conversions API" step (or the Campaign Management
170
+ API's `UetTagAuthKey/Query`; an account whose tag setup lacks that option has to ask Microsoft
171
+ Advertising support), `tagId` is the UET tag id:
172
+
173
+ ```ts
174
+ import { sendMicrosoftEvents } from '@shware/analytics/server';
175
+
176
+ await sendMicrosoftEvents(process.env.MS_ADS_CAPI_TOKEN, 97267979, events, userData, {
177
+ consent: 'granted',
178
+ });
179
+ ```
180
+
181
+ - msclkid: from url params, kept in the tag's own `_uetmsclkid` cookie (`_uet<id>`, 90 days). That
182
+ cookie is Microsoft's ITP answer, but bat.js writes it with `document.cookie`, which Safari caps
183
+ at 7 days (24h on a landing page decorated by a classified domain — a bing.com click). Register
184
+ the click-id middleware and `resolveClickIdCookies` re-issues it over HTTP on every document
185
+ response, restoring the 90-day window; give it the same `domain` the tag uses (eTLD+1) so the
186
+ two write one cookie, not two. Sent to CAPI as a dashed UUID.
187
+ - anonymousId / externalId: the SDK visitor id and SHA-256 of the user id — the same two values
188
+ `sendUETIdSync` sends as `VID` / `UID`, which Microsoft requires to match.
189
+ - Consent: the tag writes `_uetmsclkid` only with `ad_storage` granted; the middleware writes it
190
+ from the URL regardless, so gate it with the middleware's `shouldPersist` where consent applies
191
+ (as for `_fbc` and `_gcl_*`).
192
+ - Validation warnings on a 200 (a removed field, a skipped event) are logged with `console.warn`;
193
+ monitor them, the status code does not show them.
194
+ - em / ph: SHA-256 of the normalized email (dots and `+alias` stripped from the user part for every
195
+ domain, lowercase) and of the E.164 phone.
196
+ - `page_view` events are dropped by default (the tag already reports every page load); pass
197
+ `pageLoads: true` for a CAPI-only site to send them as `pageLoad` events, each `custom` event
198
+ then carrying the `pageLoadId` of the page load it happened on (the `page_load_id` tag, one
199
+ v4 UUID per page load). Not yet modelled in that mode: the revenue-only `custom` event a
200
+ destination-URL goal with variable revenue needs alongside its `pageLoad`. With the tag on the
201
+ page neither applies — the tag reports page loads itself.
202
+
127
203
  - [Click IDs](https://learn.microsoft.com/en-us/linkedin/marketing/conversions/enabling-first-party-cookies?view=li-lms-2025-10&source=recommendations): get li_fat_id from url params and cookie
@@ -2,8 +2,8 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let cookie = require("cookie");
3
3
  //#region src/click-id/index.ts
4
4
  /**
5
- * Server-side resolution of ad-click-id cookies (`_fbc`, `_gcl_aw`/`_gcl_gb`, `_rdt_cid`) from
6
- * the incoming request.
5
+ * Server-side resolution of ad-click-id cookies (`_fbc`, `_gcl_aw`/`_gcl_gb`, `_rdt_cid`,
6
+ * `_uetmsclkid`) from the incoming request.
7
7
  *
8
8
  * This is the framework-agnostic core meant to run on the *document* response (e.g. TanStack Start
9
9
  * server middleware, Next middleware). Setting `_fbc` via an HTTP `Set-Cookie` header on the top
@@ -12,16 +12,33 @@ let cookie = require("cookie");
12
12
  * cookies on a fbclid-decorated landing page to 24 hours, and the document response is never
13
13
  * classified as CNAME/IP cloaking (it is the reference the browser measures cloaking against).
14
14
  *
15
- * reference: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc
15
+ * The Google pair follows the same architecture Google itself ships for server containers —
16
+ * sGTM's Conversion Linker sets FPGCLAW via Set-Cookie for 90 days — and the sGTM ecosystem
17
+ * productized for gtag's own cookies (stape Cookie Keeper re-issues `_gcl_*` over HTTP; vendor
18
+ * case studies report 5–20% of Google Ads conversions recovered, up to +44% on the Safari
19
+ * slice). One deployment caveat from WebKit's rules: the HTTP-cookie exemption holds only for
20
+ * responses from the site's own infrastructure — a CNAME or third-party-IP host (a tagging
21
+ * subdomain on someone else's cloud) gets its Set-Cookie silently capped to 7 days. This module
22
+ * runs on the host's own document response, which is exactly the exempt case.
23
+ *
24
+ * references:
25
+ * - https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc
26
+ * - https://webkit.org/tracking-prevention/ (7d JS cookies, 24h on ad-decorated landings,
27
+ * CNAME/third-party-IP HTTP cookies capped to 7d, same-origin HTTP cookies exempt)
28
+ * - https://www.simoahava.com/analytics/google-ads-server-side-tagging-google-tag-manager/
29
+ * (Conversion Linker → FPGCLAW via Set-Cookie, Google's own server-side equivalent)
30
+ * - https://stape.io/helpdesk/documentation/cookie-keeper-power-up (the productized re-issue)
16
31
  */
17
32
  const FBC_TTL_MS = 7776e6;
18
33
  const RDT_CID_TTL_MS = 7776e6;
34
+ const MSCLKID_TTL_MS = 7776e6;
19
35
  const GCL_TTL_MS = 7776e6;
20
36
  const CLOCK_SKEW_MS = 864e5;
21
37
  const FBC_COOKIE = "_fbc";
22
38
  const RDT_CID_COOKIE = "_rdt_cid";
23
39
  const GCL_AW_COOKIE = "_gcl_aw";
24
40
  const GCL_GB_COOKIE = "_gcl_gb";
41
+ const UET_MSCLKID_COOKIE = "_uetmsclkid";
25
42
  /**
26
43
  * Parse `fb.<subdomainIndex>.<creationTime>.<fbclid>`.
27
44
  * Returns undefined for anything malformed — a fbclid never contains a dot, but joining the tail
@@ -77,6 +94,30 @@ function parseGcl(raw, now = Date.now()) {
77
94
  function formatGcl(clickId, now) {
78
95
  return `GCL.${Math.floor(now / 1e3)}.${clickId}`;
79
96
  }
97
+ const UET_MSCLKID_PREFIX = "_uet";
98
+ const MSCLKID = /^[0-9a-f]{32}$/i;
99
+ /**
100
+ * Parse a `_uetmsclkid` value: `_uet<32 hex>`. Returns the click id, lowercased, or undefined
101
+ * for anything bat.js itself would not read back.
102
+ */
103
+ function parseUetMsclkid(raw) {
104
+ if (!raw || !raw.startsWith(UET_MSCLKID_PREFIX)) return void 0;
105
+ const clickId = raw.slice(4);
106
+ return MSCLKID.test(clickId) ? clickId.toLowerCase() : void 0;
107
+ }
108
+ /** Build a `_uetmsclkid` value in bat.js's own format. */
109
+ function formatUetMsclkid(clickId) {
110
+ return `${UET_MSCLKID_PREFIX}${clickId.toLowerCase()}`;
111
+ }
112
+ /**
113
+ * The click id as the Conversions API wants it: a dashed, lowercase UUID. The URL and the cookie
114
+ * carry the 32-hex form; a value in any other shape is returned untouched for the API to judge.
115
+ */
116
+ function formatMsclkid(clickId) {
117
+ const id = clickId.trim().toLowerCase();
118
+ if (!MSCLKID.test(id)) return id;
119
+ return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
120
+ }
80
121
  function searchParams(url) {
81
122
  try {
82
123
  return new URL(url).searchParams;
@@ -163,6 +204,15 @@ function resolveClickIdCookies(input) {
163
204
  set(RDT_CID_COOKIE, urlRdtCid, RDT_CID_TTL_MS);
164
205
  result.rdt_cid = urlRdtCid;
165
206
  } else if (existingRdtCid) result.rdt_cid = existingRdtCid;
207
+ const urlMsclkid = params.get("msclkid")?.trim();
208
+ const existingMsclkid = parseUetMsclkid(jar[UET_MSCLKID_COOKIE]);
209
+ if (urlMsclkid && MSCLKID.test(urlMsclkid) && urlMsclkid.toLowerCase() !== existingMsclkid) {
210
+ set(UET_MSCLKID_COOKIE, formatUetMsclkid(urlMsclkid), MSCLKID_TTL_MS);
211
+ result.msclkid = urlMsclkid.toLowerCase();
212
+ } else if (existingMsclkid) {
213
+ result.msclkid = existingMsclkid;
214
+ if (refresh) set(UET_MSCLKID_COOKIE, formatUetMsclkid(existingMsclkid), MSCLKID_TTL_MS);
215
+ }
166
216
  return result;
167
217
  }
168
218
  /** Serialize the resolved cookies into `Set-Cookie` header values. */
@@ -174,10 +224,14 @@ exports.FBC_COOKIE = FBC_COOKIE;
174
224
  exports.GCL_AW_COOKIE = GCL_AW_COOKIE;
175
225
  exports.GCL_GB_COOKIE = GCL_GB_COOKIE;
176
226
  exports.RDT_CID_COOKIE = RDT_CID_COOKIE;
227
+ exports.UET_MSCLKID_COOKIE = UET_MSCLKID_COOKIE;
177
228
  exports.formatFbc = formatFbc;
178
229
  exports.formatGcl = formatGcl;
230
+ exports.formatMsclkid = formatMsclkid;
231
+ exports.formatUetMsclkid = formatUetMsclkid;
179
232
  exports.parseFbc = parseFbc;
180
233
  exports.parseGcl = parseGcl;
234
+ exports.parseUetMsclkid = parseUetMsclkid;
181
235
  exports.resolveClickIdCookies = resolveClickIdCookies;
182
236
  exports.toSetCookieHeaders = toSetCookieHeaders;
183
237
 
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["parseCookie","cookie","stringifySetCookie"],"sources":["../../src/click-id/index.ts"],"sourcesContent":["import { type SetCookie, parseCookie, stringifySetCookie } from 'cookie';\n\n/**\n * Server-side resolution of ad-click-id cookies (`_fbc`, `_gcl_aw`/`_gcl_gb`, `_rdt_cid`) from\n * the incoming request.\n *\n * This is the framework-agnostic core meant to run on the *document* response (e.g. TanStack Start\n * server middleware, Next middleware). Setting `_fbc` via an HTTP `Set-Cookie` header on the top\n * document — rather than `document.cookie` on the client — is what Meta officially recommends and is\n * the only reliable way to keep the cookie alive for 90 days in Safari: ITP caps JavaScript-set\n * cookies on a fbclid-decorated landing page to 24 hours, and the document response is never\n * classified as CNAME/IP cloaking (it is the reference the browser measures cloaking against).\n *\n * reference: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc\n */\n\n// Meta's recommended _fbc cookie expiry. Events Manager warns (\"Server sending expired fbclid\")\n// when the embedded creationTime is older than 90 days and match quality/attribution may degrade;\n// no hard drop is documented.\nconst FBC_TTL_MS = 90 * 24 * 60 * 60 * 1000;\nconst RDT_CID_TTL_MS = 90 * 24 * 60 * 60 * 1000;\n// gtag's own expiry for _gcl_* — 90 days, the length of a Google Ads click's upload window.\nconst GCL_TTL_MS = 90 * 24 * 60 * 60 * 1000;\n// Tolerate a little clock skew when validating a creationTime against \"now\".\nconst CLOCK_SKEW_MS = 24 * 60 * 60 * 1000;\n\nexport const FBC_COOKIE = '_fbc';\nexport const RDT_CID_COOKIE = '_rdt_cid';\nexport const GCL_AW_COOKIE = '_gcl_aw';\nexport const GCL_GB_COOKIE = '_gcl_gb';\n\nexport type ParsedFbc = { raw: string; creationTime: number; fbclid: string };\n\n/**\n * Parse `fb.<subdomainIndex>.<creationTime>.<fbclid>`.\n * Returns undefined for anything malformed — a fbclid never contains a dot, but joining the tail\n * back together keeps us forward-compatible if that ever changes.\n */\nexport function parseFbc(\n raw: string | undefined | null,\n now: number = Date.now()\n): ParsedFbc | undefined {\n if (!raw) return undefined;\n const parts = raw.split('.');\n if (parts.length < 4 || parts[0] !== 'fb') return undefined;\n\n const creationTime = Number(parts[2]);\n const fbclid = parts.slice(3).join('.');\n if (!fbclid) return undefined;\n // creationTime is UNIX ms; reject seconds-precision or future-dated values as malformed.\n if (!Number.isFinite(creationTime)) return undefined;\n if (creationTime < 1e12 || creationTime > now + CLOCK_SKEW_MS) return undefined;\n\n return { raw, creationTime, fbclid };\n}\n\n/**\n * Build a fresh `_fbc` value. subdomainIndex is the cookie's domain level (com=0, example.com=1,\n * www.example.com=2); 1 is correct for an apex-hosted cookie.\n */\nexport function formatFbc(fbclid: string, now: number, subdomainIndex = 1): string {\n return `fb.${subdomainIndex}.${now}.${fbclid}`;\n}\n\nexport type ParsedGcl = { raw: string; creationTime: number; clickId: string };\n\n// gtag's own value validator, extracted from gtag.js: the click id segment must match this.\nconst GCL_CLICK_ID = /^[\\w-]+$/;\n\n/**\n * Parse a `_gcl_aw`/`_gcl_gb` value: `GCL.<seconds>.<clickId>[.<labels…>]` — note the timestamp\n * is in SECONDS, unlike `_fbc`'s milliseconds. gtag's parser accepts version `GCL` or `1` and\n * validates the segments exactly this way (`/^\\d+$/` timestamp, `/^[\\w-]+$/` click id); any\n * labels tail is preserved through `raw` so a re-issue stays byte-identical.\n */\nexport function parseGcl(\n raw: string | undefined | null,\n now: number = Date.now()\n): ParsedGcl | undefined {\n if (!raw) return undefined;\n const parts = raw.split('.');\n if (parts.length < 3) return undefined;\n if (parts[0] !== 'GCL' && parts[0] !== '1') return undefined;\n const seconds = parts[1];\n const clickId = parts[2];\n if (seconds === undefined || !/^\\d+$/.test(seconds)) return undefined;\n if (clickId === undefined || !GCL_CLICK_ID.test(clickId)) return undefined;\n const creationTime = Number(seconds) * 1000;\n // Reject millisecond-precision or future-dated values as malformed.\n if (creationTime >= 1e15 || creationTime > now + CLOCK_SKEW_MS) return undefined;\n return { raw, creationTime, clickId };\n}\n\n/** Build a fresh `_gcl_aw`/`_gcl_gb` value in gtag's own format. */\nexport function formatGcl(clickId: string, now: number): string {\n return `GCL.${Math.floor(now / 1000)}.${clickId}`;\n}\n\nexport type ResolveClickIdCookiesInput = {\n /** The absolute request URL (must include the query string). */\n url: string;\n /** The raw `Cookie` request header, if any. */\n cookieHeader?: string | null;\n /** Overridable clock, primarily for tests. Defaults to `Date.now()`. */\n now?: number;\n /** `Domain` attribute for the emitted cookies, e.g. `.shware.io`. Omit for a host-only cookie. */\n domain?: string;\n /** `Secure` attribute, default true. Set false only for local http testing. */\n secure?: boolean;\n /** subdomainIndex for a freshly built `_fbc` (see {@link formatFbc}). Default 1. */\n subdomainIndex?: number;\n /**\n * Re-issue a still-valid `_fbc` at its *remaining* lifetime on every call. On by default, as an\n * ITP self-heal: cookies follow last-writer-wins, so if the Meta Pixel overwrote `_fbc` via\n * `document.cookie` (Safari caps JS-written cookies on a fbclid-decorated landing to 24h),\n * re-issuing the long-lived HTTP cookie on the next navigation restores it — the same\n * continuous-re-issue mitigation the sGTM ecosystem uses (stape Cookie Keeper, Cookie Monster).\n * The value (and its creationTime) is byte-for-byte identical and the window never slides, so it\n * cannot trigger Meta's expired/modified-fbclid warnings.\n *\n * Set false to strictly follow Meta's documented conditional-write rule (\"only set the cookie if\n * it doesn't exist or the fbclid changed\") — e.g. to keep pages CDN-cacheable, since the\n * re-issue attaches a per-user `Set-Cookie` (forcing `no-store`) to every response carrying an\n * `_fbc`.\n */\n refresh?: boolean;\n};\n\nexport type ResolveClickIdCookiesResult = {\n /**\n * Cookies to emit on the response, ready for `Set-Cookie`. Empty when nothing needs to change.\n * A `value: ''`, `maxAge: 0` entry is a deletion (an expired or malformed leftover).\n */\n cookies: SetCookie[];\n /** The resolved `_fbc` value, for immediate server-side use (e.g. Conversions API). */\n fbc?: string;\n /** The resolved `_rdt_cid` value. */\n rdt_cid?: string;\n /** The resolved Google Ads click id (from the URL or a still-valid `_gcl_aw`). */\n gclid?: string;\n /** The resolved iOS web-to-app click id (from the URL or a still-valid `_gcl_gb`). */\n wbraid?: string;\n};\n\nfunction searchParams(url: string): URLSearchParams {\n try {\n return new URL(url).searchParams;\n } catch {\n // Fall back to a manual split so a relative or slightly malformed URL still yields the query.\n const q = url.indexOf('?');\n return new URLSearchParams(q !== -1 ? url.slice(q + 1) : '');\n }\n}\n\n/**\n * Resolve the click-id cookies to set on the current document response.\n *\n * `_fbc` follows Meta's documented conditional-write rule value-wise: a new fbclid (or an absent\n * cookie) opens a fresh 90-day window; a same-fbclid cookie keeps its original value and\n * `creationTime`. Expired (>90d) or malformed values are cleared instead of forwarded. By default,\n * ({@link ResolveClickIdCookiesInput.refresh}) a still-valid cookie is re-issued unchanged at its\n * *remaining* lifetime — never `now + 90d` — so a returning visitor's window cannot slide forward\n * (which is what makes Meta flag an expired fbclid).\n */\nexport function resolveClickIdCookies(\n input: ResolveClickIdCookiesInput\n): ResolveClickIdCookiesResult {\n const { url, cookieHeader, domain, secure = true, subdomainIndex = 1, refresh = true } = input;\n const now = input.now ?? Date.now();\n\n const params = searchParams(url);\n const jar = parseCookie(cookieHeader ?? '');\n const cookies: SetCookie[] = [];\n const result: ResolveClickIdCookiesResult = { cookies };\n\n const base = { path: '/', secure, sameSite: 'lax', domain } as const;\n const set = (name: string, value: string, ttlMs: number) =>\n cookies.push({ name, value, maxAge: Math.floor(ttlMs / 1000), ...base });\n const del = (name: string) => cookies.push({ name, value: '', maxAge: 0, ...base });\n\n // --- Meta _fbc ---\n const urlFbclid = params.get('fbclid') || undefined;\n const existingFbc = parseFbc(jar[FBC_COOKIE], now);\n\n if (urlFbclid && urlFbclid !== existingFbc?.fbclid) {\n // A new click always wins and opens a fresh 90-day window.\n const raw = formatFbc(urlFbclid, now, subdomainIndex);\n set(FBC_COOKIE, raw, FBC_TTL_MS);\n result.fbc = raw;\n } else if (existingFbc) {\n const remainingMs = existingFbc.creationTime + FBC_TTL_MS - now;\n if (remainingMs <= 0) {\n del(FBC_COOKIE);\n } else {\n // Same fbclid: the value and creationTime are preserved (Meta's rule); refresh (default)\n // re-issues it unchanged at its remaining lifetime as an ITP self-heal.\n result.fbc = existingFbc.raw;\n if (refresh) set(FBC_COOKIE, existingFbc.raw, remainingMs);\n }\n } else if (jar[FBC_COOKIE]) {\n // Malformed leftover — clear it rather than forwarding it to Meta.\n del(FBC_COOKIE);\n }\n\n // --- Google Ads _gcl_aw / _gcl_gb ---\n // The same first-party HTTP persistence Google's own server-side Conversion Linker performs\n // (it sets FPGCLAW via Set-Cookie for 90 days), applied to gtag's cookies the way the sGTM\n // ecosystem does (stape Cookie Keeper re-issues _gcl_* over HTTP). Ownership is shared with\n // gtag, so the rules are stricter than for _fbc: a value this module cannot parse is left\n // untouched — never rewritten, never deleted — and a re-issue is byte-identical (labels tail\n // included) at the window's REMAINING lifetime, so the 90-day click window never slides.\n const gclsrc = params.get('gclsrc') || undefined;\n const google = [\n {\n cookie: GCL_AW_COOKIE,\n // gtag's own gating, verbatim: a gclid enters _gcl_aw only when gclsrc is absent or\n // 'aw.ds' — 'ds'/'3p.ds' clicks are Search Ads 360's and belong to _gcl_dc.\n urlClickId: !gclsrc || gclsrc === 'aw.ds' ? params.get('gclid') || undefined : undefined,\n assign: (value: string) => (result.gclid = value),\n },\n {\n cookie: GCL_GB_COOKIE,\n urlClickId: params.get('wbraid') || undefined,\n assign: (value: string) => (result.wbraid = value),\n },\n ];\n for (const { cookie, urlClickId, assign } of google) {\n const existing = parseGcl(jar[cookie], now);\n if (urlClickId && GCL_CLICK_ID.test(urlClickId) && urlClickId !== existing?.clickId) {\n // A new click always wins and opens a fresh 90-day window, in gtag's exact format.\n set(cookie, formatGcl(urlClickId, now), GCL_TTL_MS);\n assign(urlClickId);\n } else if (existing) {\n const remainingMs = existing.creationTime + GCL_TTL_MS - now;\n if (remainingMs > 0) {\n assign(existing.clickId);\n if (refresh) set(cookie, existing.raw, remainingMs);\n }\n // Expired: not returned and not re-issued, but gtag owns the deletion.\n }\n }\n\n // --- Reddit _rdt_cid ---\n // No embedded timestamp, so it can only be anchored at first capture; set it once from the URL and\n // otherwise leave the existing cookie untouched (re-issuing would slide its window).\n const urlRdtCid = params.get('rdt_cid') || undefined;\n const existingRdtCid = jar[RDT_CID_COOKIE] || undefined;\n if (urlRdtCid && urlRdtCid !== existingRdtCid) {\n set(RDT_CID_COOKIE, urlRdtCid, RDT_CID_TTL_MS);\n result.rdt_cid = urlRdtCid;\n } else if (existingRdtCid) {\n result.rdt_cid = existingRdtCid;\n }\n\n return result;\n}\n\n/** Serialize the resolved cookies into `Set-Cookie` header values. */\nexport function toSetCookieHeaders(cookies: SetCookie[]): string[] {\n return cookies.map((cookie) => stringifySetCookie(cookie));\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["parseCookie","cookie","stringifySetCookie"],"sources":["../../src/click-id/index.ts"],"sourcesContent":["import { type SetCookie, parseCookie, stringifySetCookie } from 'cookie';\n\n/**\n * Server-side resolution of ad-click-id cookies (`_fbc`, `_gcl_aw`/`_gcl_gb`, `_rdt_cid`,\n * `_uetmsclkid`) from the incoming request.\n *\n * This is the framework-agnostic core meant to run on the *document* response (e.g. TanStack Start\n * server middleware, Next middleware). Setting `_fbc` via an HTTP `Set-Cookie` header on the top\n * document — rather than `document.cookie` on the client — is what Meta officially recommends and is\n * the only reliable way to keep the cookie alive for 90 days in Safari: ITP caps JavaScript-set\n * cookies on a fbclid-decorated landing page to 24 hours, and the document response is never\n * classified as CNAME/IP cloaking (it is the reference the browser measures cloaking against).\n *\n * The Google pair follows the same architecture Google itself ships for server containers —\n * sGTM's Conversion Linker sets FPGCLAW via Set-Cookie for 90 days — and the sGTM ecosystem\n * productized for gtag's own cookies (stape Cookie Keeper re-issues `_gcl_*` over HTTP; vendor\n * case studies report 5–20% of Google Ads conversions recovered, up to +44% on the Safari\n * slice). One deployment caveat from WebKit's rules: the HTTP-cookie exemption holds only for\n * responses from the site's own infrastructure — a CNAME or third-party-IP host (a tagging\n * subdomain on someone else's cloud) gets its Set-Cookie silently capped to 7 days. This module\n * runs on the host's own document response, which is exactly the exempt case.\n *\n * references:\n * - https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc\n * - https://webkit.org/tracking-prevention/ (7d JS cookies, 24h on ad-decorated landings,\n * CNAME/third-party-IP HTTP cookies capped to 7d, same-origin HTTP cookies exempt)\n * - https://www.simoahava.com/analytics/google-ads-server-side-tagging-google-tag-manager/\n * (Conversion Linker → FPGCLAW via Set-Cookie, Google's own server-side equivalent)\n * - https://stape.io/helpdesk/documentation/cookie-keeper-power-up (the productized re-issue)\n */\n\n// Meta's recommended _fbc cookie expiry. Events Manager warns (\"Server sending expired fbclid\")\n// when the embedded creationTime is older than 90 days and match quality/attribution may degrade;\n// no hard drop is documented.\nconst FBC_TTL_MS = 90 * 24 * 60 * 60 * 1000;\nconst RDT_CID_TTL_MS = 90 * 24 * 60 * 60 * 1000;\n// bat.js's own expiry for _uetmsclkid (`msClkIdExpirationTime`), the Microsoft Ads click window.\nconst MSCLKID_TTL_MS = 90 * 24 * 60 * 60 * 1000;\n// gtag's own expiry for _gcl_* — 90 days, the length of a Google Ads click's upload window.\nconst GCL_TTL_MS = 90 * 24 * 60 * 60 * 1000;\n// Tolerate a little clock skew when validating a creationTime against \"now\".\nconst CLOCK_SKEW_MS = 24 * 60 * 60 * 1000;\n\nexport const FBC_COOKIE = '_fbc';\nexport const RDT_CID_COOKIE = '_rdt_cid';\nexport const GCL_AW_COOKIE = '_gcl_aw';\nexport const GCL_GB_COOKIE = '_gcl_gb';\nexport const UET_MSCLKID_COOKIE = '_uetmsclkid';\n\nexport type ParsedFbc = { raw: string; creationTime: number; fbclid: string };\n\n/**\n * Parse `fb.<subdomainIndex>.<creationTime>.<fbclid>`.\n * Returns undefined for anything malformed — a fbclid never contains a dot, but joining the tail\n * back together keeps us forward-compatible if that ever changes.\n */\nexport function parseFbc(\n raw: string | undefined | null,\n now: number = Date.now()\n): ParsedFbc | undefined {\n if (!raw) return undefined;\n const parts = raw.split('.');\n if (parts.length < 4 || parts[0] !== 'fb') return undefined;\n\n const creationTime = Number(parts[2]);\n const fbclid = parts.slice(3).join('.');\n if (!fbclid) return undefined;\n // creationTime is UNIX ms; reject seconds-precision or future-dated values as malformed.\n if (!Number.isFinite(creationTime)) return undefined;\n if (creationTime < 1e12 || creationTime > now + CLOCK_SKEW_MS) return undefined;\n\n return { raw, creationTime, fbclid };\n}\n\n/**\n * Build a fresh `_fbc` value. subdomainIndex is the cookie's domain level (com=0, example.com=1,\n * www.example.com=2); 1 is correct for an apex-hosted cookie.\n */\nexport function formatFbc(fbclid: string, now: number, subdomainIndex = 1): string {\n return `fb.${subdomainIndex}.${now}.${fbclid}`;\n}\n\nexport type ParsedGcl = { raw: string; creationTime: number; clickId: string };\n\n// gtag's own value validator, extracted from the live gtag.js bundle (2026-09): its parser\n// accepts version \"GCL\" or \"1\", a /^\\d+$/ seconds timestamp, and a click id matching this.\n// Re-verify against gtag.js before changing anything here — the format is gtag's, not ours.\nconst GCL_CLICK_ID = /^[\\w-]+$/;\n\n/**\n * Parse a `_gcl_aw`/`_gcl_gb` value: `GCL.<seconds>.<clickId>[.<labels…>]` — note the timestamp\n * is in SECONDS, unlike `_fbc`'s milliseconds. gtag's parser accepts version `GCL` or `1` and\n * validates the segments exactly this way (`/^\\d+$/` timestamp, `/^[\\w-]+$/` click id); any\n * labels tail is preserved through `raw` so a re-issue stays byte-identical.\n */\nexport function parseGcl(\n raw: string | undefined | null,\n now: number = Date.now()\n): ParsedGcl | undefined {\n if (!raw) return undefined;\n const parts = raw.split('.');\n if (parts.length < 3) return undefined;\n if (parts[0] !== 'GCL' && parts[0] !== '1') return undefined;\n const seconds = parts[1];\n const clickId = parts[2];\n if (seconds === undefined || !/^\\d+$/.test(seconds)) return undefined;\n if (clickId === undefined || !GCL_CLICK_ID.test(clickId)) return undefined;\n const creationTime = Number(seconds) * 1000;\n // Reject millisecond-precision or future-dated values as malformed.\n if (creationTime >= 1e15 || creationTime > now + CLOCK_SKEW_MS) return undefined;\n return { raw, creationTime, clickId };\n}\n\n/** Build a fresh `_gcl_aw`/`_gcl_gb` value in gtag's own format. */\nexport function formatGcl(clickId: string, now: number): string {\n return `GCL.${Math.floor(now / 1000)}.${clickId}`;\n}\n\n// bat.js writes the click id as `_uet<msclkid>` and reads it back only when what follows the\n// prefix is at most 32 characters (`msClkIdCookieValuePrefix`, `lengthMsClkId`); a msclkid is a\n// 32-hex-digit UUID without dashes. Re-verify against bat.js before changing anything here —\n// the format is UET's, not ours.\nconst UET_MSCLKID_PREFIX = '_uet';\nconst MSCLKID = /^[0-9a-f]{32}$/i;\n\n/**\n * Parse a `_uetmsclkid` value: `_uet<32 hex>`. Returns the click id, lowercased, or undefined\n * for anything bat.js itself would not read back.\n */\nexport function parseUetMsclkid(raw: string | undefined | null): string | undefined {\n if (!raw || !raw.startsWith(UET_MSCLKID_PREFIX)) return undefined;\n const clickId = raw.slice(UET_MSCLKID_PREFIX.length);\n return MSCLKID.test(clickId) ? clickId.toLowerCase() : undefined;\n}\n\n/** Build a `_uetmsclkid` value in bat.js's own format. */\nexport function formatUetMsclkid(clickId: string): string {\n return `${UET_MSCLKID_PREFIX}${clickId.toLowerCase()}`;\n}\n\n/**\n * The click id as the Conversions API wants it: a dashed, lowercase UUID. The URL and the cookie\n * carry the 32-hex form; a value in any other shape is returned untouched for the API to judge.\n */\nexport function formatMsclkid(clickId: string): string {\n const id = clickId.trim().toLowerCase();\n if (!MSCLKID.test(id)) return id;\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;\n}\n\nexport type ResolveClickIdCookiesInput = {\n /** The absolute request URL (must include the query string). */\n url: string;\n /** The raw `Cookie` request header, if any. */\n cookieHeader?: string | null;\n /** Overridable clock, primarily for tests. Defaults to `Date.now()`. */\n now?: number;\n /** `Domain` attribute for the emitted cookies, e.g. `.shware.io`. Omit for a host-only cookie. */\n domain?: string;\n /** `Secure` attribute, default true. Set false only for local http testing. */\n secure?: boolean;\n /** subdomainIndex for a freshly built `_fbc` (see {@link formatFbc}). Default 1. */\n subdomainIndex?: number;\n /**\n * Re-issue a still-valid `_fbc` at its *remaining* lifetime on every call. On by default, as an\n * ITP self-heal: cookies follow last-writer-wins, so if the Meta Pixel overwrote `_fbc` via\n * `document.cookie` (Safari caps JS-written cookies on a fbclid-decorated landing to 24h),\n * re-issuing the long-lived HTTP cookie on the next navigation restores it — the same\n * continuous-re-issue mitigation the sGTM ecosystem uses (stape Cookie Keeper, Cookie Monster).\n * The value (and its creationTime) is byte-for-byte identical and the window never slides, so it\n * cannot trigger Meta's expired/modified-fbclid warnings.\n *\n * Set false to strictly follow Meta's documented conditional-write rule (\"only set the cookie if\n * it doesn't exist or the fbclid changed\") — e.g. to keep pages CDN-cacheable, since the\n * re-issue attaches a per-user `Set-Cookie` (forcing `no-store`) to every response carrying an\n * `_fbc`.\n */\n refresh?: boolean;\n};\n\nexport type ResolveClickIdCookiesResult = {\n /**\n * Cookies to emit on the response, ready for `Set-Cookie`. Empty when nothing needs to change.\n * A `value: ''`, `maxAge: 0` entry is a deletion (an expired or malformed leftover).\n */\n cookies: SetCookie[];\n /** The resolved `_fbc` value, for immediate server-side use (e.g. Conversions API). */\n fbc?: string;\n /** The resolved `_rdt_cid` value. */\n rdt_cid?: string;\n /** The resolved Google Ads click id (from the URL or a still-valid `_gcl_aw`). */\n gclid?: string;\n /** The resolved iOS web-to-app click id (from the URL or a still-valid `_gcl_gb`). */\n wbraid?: string;\n /** The resolved Microsoft Ads click id (from the URL or `_uetmsclkid`), 32-hex form. */\n msclkid?: string;\n};\n\nfunction searchParams(url: string): URLSearchParams {\n try {\n return new URL(url).searchParams;\n } catch {\n // Fall back to a manual split so a relative or slightly malformed URL still yields the query.\n const q = url.indexOf('?');\n return new URLSearchParams(q !== -1 ? url.slice(q + 1) : '');\n }\n}\n\n/**\n * Resolve the click-id cookies to set on the current document response.\n *\n * `_fbc` follows Meta's documented conditional-write rule value-wise: a new fbclid (or an absent\n * cookie) opens a fresh 90-day window; a same-fbclid cookie keeps its original value and\n * `creationTime`. Expired (>90d) or malformed values are cleared instead of forwarded. By default,\n * ({@link ResolveClickIdCookiesInput.refresh}) a still-valid cookie is re-issued unchanged at its\n * *remaining* lifetime — never `now + 90d` — so a returning visitor's window cannot slide forward\n * (which is what makes Meta flag an expired fbclid).\n */\nexport function resolveClickIdCookies(\n input: ResolveClickIdCookiesInput\n): ResolveClickIdCookiesResult {\n const { url, cookieHeader, domain, secure = true, subdomainIndex = 1, refresh = true } = input;\n const now = input.now ?? Date.now();\n\n const params = searchParams(url);\n const jar = parseCookie(cookieHeader ?? '');\n const cookies: SetCookie[] = [];\n const result: ResolveClickIdCookiesResult = { cookies };\n\n const base = { path: '/', secure, sameSite: 'lax', domain } as const;\n const set = (name: string, value: string, ttlMs: number) =>\n cookies.push({ name, value, maxAge: Math.floor(ttlMs / 1000), ...base });\n const del = (name: string) => cookies.push({ name, value: '', maxAge: 0, ...base });\n\n // --- Meta _fbc ---\n const urlFbclid = params.get('fbclid') || undefined;\n const existingFbc = parseFbc(jar[FBC_COOKIE], now);\n\n if (urlFbclid && urlFbclid !== existingFbc?.fbclid) {\n // A new click always wins and opens a fresh 90-day window.\n const raw = formatFbc(urlFbclid, now, subdomainIndex);\n set(FBC_COOKIE, raw, FBC_TTL_MS);\n result.fbc = raw;\n } else if (existingFbc) {\n const remainingMs = existingFbc.creationTime + FBC_TTL_MS - now;\n if (remainingMs <= 0) {\n del(FBC_COOKIE);\n } else {\n // Same fbclid: the value and creationTime are preserved (Meta's rule); refresh (default)\n // re-issues it unchanged at its remaining lifetime as an ITP self-heal.\n result.fbc = existingFbc.raw;\n if (refresh) set(FBC_COOKIE, existingFbc.raw, remainingMs);\n }\n } else if (jar[FBC_COOKIE]) {\n // Malformed leftover — clear it rather than forwarding it to Meta.\n del(FBC_COOKIE);\n }\n\n // --- Google Ads _gcl_aw / _gcl_gb ---\n // The same first-party HTTP persistence Google's own server-side Conversion Linker performs\n // (it sets FPGCLAW via Set-Cookie for 90 days), applied to gtag's cookies the way the sGTM\n // ecosystem does (stape Cookie Keeper re-issues _gcl_* over HTTP). Ownership is shared with\n // gtag, so the rules are stricter than for _fbc: a value this module cannot parse is left\n // untouched — never rewritten, never deleted — and a re-issue is byte-identical (labels tail\n // included) at the window's REMAINING lifetime, so the 90-day click window never slides.\n const gclsrc = params.get('gclsrc') || undefined;\n const google = [\n {\n cookie: GCL_AW_COOKIE,\n // gtag's own gating, verbatim: a gclid enters _gcl_aw only when gclsrc is absent or\n // 'aw.ds' — 'ds'/'3p.ds' clicks are Search Ads 360's and belong to _gcl_dc.\n urlClickId: !gclsrc || gclsrc === 'aw.ds' ? params.get('gclid') || undefined : undefined,\n assign: (value: string) => (result.gclid = value),\n },\n {\n cookie: GCL_GB_COOKIE,\n urlClickId: params.get('wbraid') || undefined,\n assign: (value: string) => (result.wbraid = value),\n },\n ];\n for (const { cookie, urlClickId, assign } of google) {\n const existing = parseGcl(jar[cookie], now);\n if (urlClickId && GCL_CLICK_ID.test(urlClickId) && urlClickId !== existing?.clickId) {\n // A new click always wins and opens a fresh 90-day window, in gtag's exact format.\n set(cookie, formatGcl(urlClickId, now), GCL_TTL_MS);\n assign(urlClickId);\n } else if (existing) {\n const remainingMs = existing.creationTime + GCL_TTL_MS - now;\n if (remainingMs > 0) {\n assign(existing.clickId);\n if (refresh) set(cookie, existing.raw, remainingMs);\n }\n // Expired: not returned and not re-issued, but gtag owns the deletion.\n }\n }\n\n // --- Reddit _rdt_cid ---\n // No embedded timestamp, so it can only be anchored at first capture; set it once from the URL and\n // otherwise leave the existing cookie untouched (re-issuing would slide its window).\n const urlRdtCid = params.get('rdt_cid') || undefined;\n const existingRdtCid = jar[RDT_CID_COOKIE] || undefined;\n if (urlRdtCid && urlRdtCid !== existingRdtCid) {\n set(RDT_CID_COOKIE, urlRdtCid, RDT_CID_TTL_MS);\n result.rdt_cid = urlRdtCid;\n } else if (existingRdtCid) {\n result.rdt_cid = existingRdtCid;\n }\n\n // --- Microsoft Ads _uetmsclkid ---\n // The UET tag's own cookie, in its own format, so the tag and this module read each other's\n // writes. Microsoft's ITP answer is this first-party cookie (plus a localStorage backup), but\n // bat.js writes it through `document.cookie`, which Safari caps at 7 days — 24 hours on a\n // landing page decorated by a classified domain, and a bing.com ad click is exactly that.\n // Persisting it here over HTTP on the document response restores the 90-day window.\n //\n // The re-issue (default on, see `refresh`) is the same self-heal as for `_fbc`, and it matters\n // more here: bat.js rewrites this cookie on EVERY page load, so without the re-issue the HTTP\n // copy is downgraded to a JS cookie on the very next page. There is no embedded timestamp to\n // compute a remaining lifetime from, so it is re-issued at a fresh 90 days — which is exactly\n // what bat.js itself does on each page (`msClkIdExpirationTime` from now), so the window\n // slides no more than the tag already slides it. A value this module cannot parse belongs to\n // the tag: never rewritten, never deleted.\n const urlMsclkid = params.get('msclkid')?.trim();\n const existingMsclkid = parseUetMsclkid(jar[UET_MSCLKID_COOKIE]);\n if (urlMsclkid && MSCLKID.test(urlMsclkid) && urlMsclkid.toLowerCase() !== existingMsclkid) {\n set(UET_MSCLKID_COOKIE, formatUetMsclkid(urlMsclkid), MSCLKID_TTL_MS);\n result.msclkid = urlMsclkid.toLowerCase();\n } else if (existingMsclkid) {\n result.msclkid = existingMsclkid;\n if (refresh) set(UET_MSCLKID_COOKIE, formatUetMsclkid(existingMsclkid), MSCLKID_TTL_MS);\n }\n\n return result;\n}\n\n/** Serialize the resolved cookies into `Set-Cookie` header values. */\nexport function toSetCookieHeaders(cookies: SetCookie[]): string[] {\n return cookies.map((cookie) => stringifySetCookie(cookie));\n}\n"],"mappings":"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"}
@@ -4,6 +4,7 @@ declare const FBC_COOKIE = "_fbc";
4
4
  declare const RDT_CID_COOKIE = "_rdt_cid";
5
5
  declare const GCL_AW_COOKIE = "_gcl_aw";
6
6
  declare const GCL_GB_COOKIE = "_gcl_gb";
7
+ declare const UET_MSCLKID_COOKIE = "_uetmsclkid";
7
8
  type ParsedFbc = {
8
9
  raw: string;
9
10
  creationTime: number;
@@ -34,6 +35,18 @@ type ParsedGcl = {
34
35
  declare function parseGcl(raw: string | undefined | null, now?: number): ParsedGcl | undefined;
35
36
  /** Build a fresh `_gcl_aw`/`_gcl_gb` value in gtag's own format. */
36
37
  declare function formatGcl(clickId: string, now: number): string;
38
+ /**
39
+ * Parse a `_uetmsclkid` value: `_uet<32 hex>`. Returns the click id, lowercased, or undefined
40
+ * for anything bat.js itself would not read back.
41
+ */
42
+ declare function parseUetMsclkid(raw: string | undefined | null): string | undefined;
43
+ /** Build a `_uetmsclkid` value in bat.js's own format. */
44
+ declare function formatUetMsclkid(clickId: string): string;
45
+ /**
46
+ * The click id as the Conversions API wants it: a dashed, lowercase UUID. The URL and the cookie
47
+ * carry the 32-hex form; a value in any other shape is returned untouched for the API to judge.
48
+ */
49
+ declare function formatMsclkid(clickId: string): string;
37
50
  type ResolveClickIdCookiesInput = {
38
51
  /** The absolute request URL (must include the query string). */
39
52
  url: string;
@@ -77,6 +90,8 @@ type ResolveClickIdCookiesResult = {
77
90
  gclid?: string;
78
91
  /** The resolved iOS web-to-app click id (from the URL or a still-valid `_gcl_gb`). */
79
92
  wbraid?: string;
93
+ /** The resolved Microsoft Ads click id (from the URL or `_uetmsclkid`), 32-hex form. */
94
+ msclkid?: string;
80
95
  };
81
96
  /**
82
97
  * Resolve the click-id cookies to set on the current document response.
@@ -92,5 +107,5 @@ declare function resolveClickIdCookies(input: ResolveClickIdCookiesInput): Resol
92
107
  /** Serialize the resolved cookies into `Set-Cookie` header values. */
93
108
  declare function toSetCookieHeaders(cookies: SetCookie[]): string[];
94
109
  //#endregion
95
- export { FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, ParsedFbc, ParsedGcl, RDT_CID_COOKIE, ResolveClickIdCookiesInput, ResolveClickIdCookiesResult, formatFbc, formatGcl, parseFbc, parseGcl, resolveClickIdCookies, toSetCookieHeaders };
110
+ export { FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, ParsedFbc, ParsedGcl, RDT_CID_COOKIE, ResolveClickIdCookiesInput, ResolveClickIdCookiesResult, UET_MSCLKID_COOKIE, formatFbc, formatGcl, formatMsclkid, formatUetMsclkid, parseFbc, parseGcl, parseUetMsclkid, resolveClickIdCookies, toSetCookieHeaders };
96
111
  //# sourceMappingURL=index.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/click-id/index.ts"],"mappings":";;cA0Ba;cACA;cACA;cACA;KAED;EAAc;EAAa;EAAsB;;;;;;;iBAO7C,SACd,gCACA,eACC;;;;;iBAmBa,UAAU,gBAAgB,aAAa;KAI3C;EAAc;EAAa;EAAsB;;;;;;;;iBAW7C,SACd,gCACA,eACC;;iBAgBa,UAAU,iBAAiB;KAI/B;;EAEV;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;;;;EAeA;;KAGU;;;;;EAKV,SAAS;;EAET;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;iBAuBc,sBACd,OAAO,6BACN;;iBA4Fa,mBAAmB,SAAS"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/click-id/index.ts"],"mappings":";;cA2Ca;cACA;cACA;cACA;cACA;KAED;EAAc;EAAa;EAAsB;;;;;;;iBAO7C,SACd,gCACA,eACC;;;;;iBAmBa,UAAU,gBAAgB,aAAa;KAI3C;EAAc;EAAa;EAAsB;;;;;;;;iBAa7C,SACd,gCACA,eACC;;iBAgBa,UAAU,iBAAiB;;;;;iBAe3B,gBAAgB;;iBAOhB,iBAAiB;;;;;iBAQjB,cAAc;KAMlB;;EAEV;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;;;;EAeA;;KAGU;;;;;EAKV,SAAS;;EAET;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;iBAuBc,sBACd,OAAO,6BACN;;iBAoHa,mBAAmB,SAAS"}
@@ -4,6 +4,7 @@ declare const FBC_COOKIE = "_fbc";
4
4
  declare const RDT_CID_COOKIE = "_rdt_cid";
5
5
  declare const GCL_AW_COOKIE = "_gcl_aw";
6
6
  declare const GCL_GB_COOKIE = "_gcl_gb";
7
+ declare const UET_MSCLKID_COOKIE = "_uetmsclkid";
7
8
  type ParsedFbc = {
8
9
  raw: string;
9
10
  creationTime: number;
@@ -34,6 +35,18 @@ type ParsedGcl = {
34
35
  declare function parseGcl(raw: string | undefined | null, now?: number): ParsedGcl | undefined;
35
36
  /** Build a fresh `_gcl_aw`/`_gcl_gb` value in gtag's own format. */
36
37
  declare function formatGcl(clickId: string, now: number): string;
38
+ /**
39
+ * Parse a `_uetmsclkid` value: `_uet<32 hex>`. Returns the click id, lowercased, or undefined
40
+ * for anything bat.js itself would not read back.
41
+ */
42
+ declare function parseUetMsclkid(raw: string | undefined | null): string | undefined;
43
+ /** Build a `_uetmsclkid` value in bat.js's own format. */
44
+ declare function formatUetMsclkid(clickId: string): string;
45
+ /**
46
+ * The click id as the Conversions API wants it: a dashed, lowercase UUID. The URL and the cookie
47
+ * carry the 32-hex form; a value in any other shape is returned untouched for the API to judge.
48
+ */
49
+ declare function formatMsclkid(clickId: string): string;
37
50
  type ResolveClickIdCookiesInput = {
38
51
  /** The absolute request URL (must include the query string). */
39
52
  url: string;
@@ -77,6 +90,8 @@ type ResolveClickIdCookiesResult = {
77
90
  gclid?: string;
78
91
  /** The resolved iOS web-to-app click id (from the URL or a still-valid `_gcl_gb`). */
79
92
  wbraid?: string;
93
+ /** The resolved Microsoft Ads click id (from the URL or `_uetmsclkid`), 32-hex form. */
94
+ msclkid?: string;
80
95
  };
81
96
  /**
82
97
  * Resolve the click-id cookies to set on the current document response.
@@ -92,5 +107,5 @@ declare function resolveClickIdCookies(input: ResolveClickIdCookiesInput): Resol
92
107
  /** Serialize the resolved cookies into `Set-Cookie` header values. */
93
108
  declare function toSetCookieHeaders(cookies: SetCookie[]): string[];
94
109
  //#endregion
95
- export { FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, ParsedFbc, ParsedGcl, RDT_CID_COOKIE, ResolveClickIdCookiesInput, ResolveClickIdCookiesResult, formatFbc, formatGcl, parseFbc, parseGcl, resolveClickIdCookies, toSetCookieHeaders };
110
+ export { FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, ParsedFbc, ParsedGcl, RDT_CID_COOKIE, ResolveClickIdCookiesInput, ResolveClickIdCookiesResult, UET_MSCLKID_COOKIE, formatFbc, formatGcl, formatMsclkid, formatUetMsclkid, parseFbc, parseGcl, parseUetMsclkid, resolveClickIdCookies, toSetCookieHeaders };
96
111
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/click-id/index.ts"],"mappings":";;cA0Ba;cACA;cACA;cACA;KAED;EAAc;EAAa;EAAsB;;;;;;;iBAO7C,SACd,gCACA,eACC;;;;;iBAmBa,UAAU,gBAAgB,aAAa;KAI3C;EAAc;EAAa;EAAsB;;;;;;;;iBAW7C,SACd,gCACA,eACC;;iBAgBa,UAAU,iBAAiB;KAI/B;;EAEV;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;;;;EAeA;;KAGU;;;;;EAKV,SAAS;;EAET;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;iBAuBc,sBACd,OAAO,6BACN;;iBA4Fa,mBAAmB,SAAS"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/click-id/index.ts"],"mappings":";;cA2Ca;cACA;cACA;cACA;cACA;KAED;EAAc;EAAa;EAAsB;;;;;;;iBAO7C,SACd,gCACA,eACC;;;;;iBAmBa,UAAU,gBAAgB,aAAa;KAI3C;EAAc;EAAa;EAAsB;;;;;;;;iBAa7C,SACd,gCACA,eACC;;iBAgBa,UAAU,iBAAiB;;;;;iBAe3B,gBAAgB;;iBAOhB,iBAAiB;;;;;iBAQjB,cAAc;KAMlB;;EAEV;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;;;;EAeA;;KAGU;;;;;EAKV,SAAS;;EAET;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;iBAuBc,sBACd,OAAO,6BACN;;iBAoHa,mBAAmB,SAAS"}
@@ -1,8 +1,8 @@
1
1
  import { parseCookie, stringifySetCookie } from "cookie";
2
2
  //#region src/click-id/index.ts
3
3
  /**
4
- * Server-side resolution of ad-click-id cookies (`_fbc`, `_gcl_aw`/`_gcl_gb`, `_rdt_cid`) from
5
- * the incoming request.
4
+ * Server-side resolution of ad-click-id cookies (`_fbc`, `_gcl_aw`/`_gcl_gb`, `_rdt_cid`,
5
+ * `_uetmsclkid`) from the incoming request.
6
6
  *
7
7
  * This is the framework-agnostic core meant to run on the *document* response (e.g. TanStack Start
8
8
  * server middleware, Next middleware). Setting `_fbc` via an HTTP `Set-Cookie` header on the top
@@ -11,16 +11,33 @@ import { parseCookie, stringifySetCookie } from "cookie";
11
11
  * cookies on a fbclid-decorated landing page to 24 hours, and the document response is never
12
12
  * classified as CNAME/IP cloaking (it is the reference the browser measures cloaking against).
13
13
  *
14
- * reference: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc
14
+ * The Google pair follows the same architecture Google itself ships for server containers —
15
+ * sGTM's Conversion Linker sets FPGCLAW via Set-Cookie for 90 days — and the sGTM ecosystem
16
+ * productized for gtag's own cookies (stape Cookie Keeper re-issues `_gcl_*` over HTTP; vendor
17
+ * case studies report 5–20% of Google Ads conversions recovered, up to +44% on the Safari
18
+ * slice). One deployment caveat from WebKit's rules: the HTTP-cookie exemption holds only for
19
+ * responses from the site's own infrastructure — a CNAME or third-party-IP host (a tagging
20
+ * subdomain on someone else's cloud) gets its Set-Cookie silently capped to 7 days. This module
21
+ * runs on the host's own document response, which is exactly the exempt case.
22
+ *
23
+ * references:
24
+ * - https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc
25
+ * - https://webkit.org/tracking-prevention/ (7d JS cookies, 24h on ad-decorated landings,
26
+ * CNAME/third-party-IP HTTP cookies capped to 7d, same-origin HTTP cookies exempt)
27
+ * - https://www.simoahava.com/analytics/google-ads-server-side-tagging-google-tag-manager/
28
+ * (Conversion Linker → FPGCLAW via Set-Cookie, Google's own server-side equivalent)
29
+ * - https://stape.io/helpdesk/documentation/cookie-keeper-power-up (the productized re-issue)
15
30
  */
16
31
  const FBC_TTL_MS = 7776e6;
17
32
  const RDT_CID_TTL_MS = 7776e6;
33
+ const MSCLKID_TTL_MS = 7776e6;
18
34
  const GCL_TTL_MS = 7776e6;
19
35
  const CLOCK_SKEW_MS = 864e5;
20
36
  const FBC_COOKIE = "_fbc";
21
37
  const RDT_CID_COOKIE = "_rdt_cid";
22
38
  const GCL_AW_COOKIE = "_gcl_aw";
23
39
  const GCL_GB_COOKIE = "_gcl_gb";
40
+ const UET_MSCLKID_COOKIE = "_uetmsclkid";
24
41
  /**
25
42
  * Parse `fb.<subdomainIndex>.<creationTime>.<fbclid>`.
26
43
  * Returns undefined for anything malformed — a fbclid never contains a dot, but joining the tail
@@ -76,6 +93,30 @@ function parseGcl(raw, now = Date.now()) {
76
93
  function formatGcl(clickId, now) {
77
94
  return `GCL.${Math.floor(now / 1e3)}.${clickId}`;
78
95
  }
96
+ const UET_MSCLKID_PREFIX = "_uet";
97
+ const MSCLKID = /^[0-9a-f]{32}$/i;
98
+ /**
99
+ * Parse a `_uetmsclkid` value: `_uet<32 hex>`. Returns the click id, lowercased, or undefined
100
+ * for anything bat.js itself would not read back.
101
+ */
102
+ function parseUetMsclkid(raw) {
103
+ if (!raw || !raw.startsWith(UET_MSCLKID_PREFIX)) return void 0;
104
+ const clickId = raw.slice(4);
105
+ return MSCLKID.test(clickId) ? clickId.toLowerCase() : void 0;
106
+ }
107
+ /** Build a `_uetmsclkid` value in bat.js's own format. */
108
+ function formatUetMsclkid(clickId) {
109
+ return `${UET_MSCLKID_PREFIX}${clickId.toLowerCase()}`;
110
+ }
111
+ /**
112
+ * The click id as the Conversions API wants it: a dashed, lowercase UUID. The URL and the cookie
113
+ * carry the 32-hex form; a value in any other shape is returned untouched for the API to judge.
114
+ */
115
+ function formatMsclkid(clickId) {
116
+ const id = clickId.trim().toLowerCase();
117
+ if (!MSCLKID.test(id)) return id;
118
+ return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
119
+ }
79
120
  function searchParams(url) {
80
121
  try {
81
122
  return new URL(url).searchParams;
@@ -162,6 +203,15 @@ function resolveClickIdCookies(input) {
162
203
  set(RDT_CID_COOKIE, urlRdtCid, RDT_CID_TTL_MS);
163
204
  result.rdt_cid = urlRdtCid;
164
205
  } else if (existingRdtCid) result.rdt_cid = existingRdtCid;
206
+ const urlMsclkid = params.get("msclkid")?.trim();
207
+ const existingMsclkid = parseUetMsclkid(jar[UET_MSCLKID_COOKIE]);
208
+ if (urlMsclkid && MSCLKID.test(urlMsclkid) && urlMsclkid.toLowerCase() !== existingMsclkid) {
209
+ set(UET_MSCLKID_COOKIE, formatUetMsclkid(urlMsclkid), MSCLKID_TTL_MS);
210
+ result.msclkid = urlMsclkid.toLowerCase();
211
+ } else if (existingMsclkid) {
212
+ result.msclkid = existingMsclkid;
213
+ if (refresh) set(UET_MSCLKID_COOKIE, formatUetMsclkid(existingMsclkid), MSCLKID_TTL_MS);
214
+ }
165
215
  return result;
166
216
  }
167
217
  /** Serialize the resolved cookies into `Set-Cookie` header values. */
@@ -169,6 +219,6 @@ function toSetCookieHeaders(cookies) {
169
219
  return cookies.map((cookie) => stringifySetCookie(cookie));
170
220
  }
171
221
  //#endregion
172
- export { FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, RDT_CID_COOKIE, formatFbc, formatGcl, parseFbc, parseGcl, resolveClickIdCookies, toSetCookieHeaders };
222
+ export { FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, RDT_CID_COOKIE, UET_MSCLKID_COOKIE, formatFbc, formatGcl, formatMsclkid, formatUetMsclkid, parseFbc, parseGcl, parseUetMsclkid, resolveClickIdCookies, toSetCookieHeaders };
173
223
 
174
224
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/click-id/index.ts"],"sourcesContent":["import { type SetCookie, parseCookie, stringifySetCookie } from 'cookie';\n\n/**\n * Server-side resolution of ad-click-id cookies (`_fbc`, `_gcl_aw`/`_gcl_gb`, `_rdt_cid`) from\n * the incoming request.\n *\n * This is the framework-agnostic core meant to run on the *document* response (e.g. TanStack Start\n * server middleware, Next middleware). Setting `_fbc` via an HTTP `Set-Cookie` header on the top\n * document — rather than `document.cookie` on the client — is what Meta officially recommends and is\n * the only reliable way to keep the cookie alive for 90 days in Safari: ITP caps JavaScript-set\n * cookies on a fbclid-decorated landing page to 24 hours, and the document response is never\n * classified as CNAME/IP cloaking (it is the reference the browser measures cloaking against).\n *\n * reference: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc\n */\n\n// Meta's recommended _fbc cookie expiry. Events Manager warns (\"Server sending expired fbclid\")\n// when the embedded creationTime is older than 90 days and match quality/attribution may degrade;\n// no hard drop is documented.\nconst FBC_TTL_MS = 90 * 24 * 60 * 60 * 1000;\nconst RDT_CID_TTL_MS = 90 * 24 * 60 * 60 * 1000;\n// gtag's own expiry for _gcl_* — 90 days, the length of a Google Ads click's upload window.\nconst GCL_TTL_MS = 90 * 24 * 60 * 60 * 1000;\n// Tolerate a little clock skew when validating a creationTime against \"now\".\nconst CLOCK_SKEW_MS = 24 * 60 * 60 * 1000;\n\nexport const FBC_COOKIE = '_fbc';\nexport const RDT_CID_COOKIE = '_rdt_cid';\nexport const GCL_AW_COOKIE = '_gcl_aw';\nexport const GCL_GB_COOKIE = '_gcl_gb';\n\nexport type ParsedFbc = { raw: string; creationTime: number; fbclid: string };\n\n/**\n * Parse `fb.<subdomainIndex>.<creationTime>.<fbclid>`.\n * Returns undefined for anything malformed — a fbclid never contains a dot, but joining the tail\n * back together keeps us forward-compatible if that ever changes.\n */\nexport function parseFbc(\n raw: string | undefined | null,\n now: number = Date.now()\n): ParsedFbc | undefined {\n if (!raw) return undefined;\n const parts = raw.split('.');\n if (parts.length < 4 || parts[0] !== 'fb') return undefined;\n\n const creationTime = Number(parts[2]);\n const fbclid = parts.slice(3).join('.');\n if (!fbclid) return undefined;\n // creationTime is UNIX ms; reject seconds-precision or future-dated values as malformed.\n if (!Number.isFinite(creationTime)) return undefined;\n if (creationTime < 1e12 || creationTime > now + CLOCK_SKEW_MS) return undefined;\n\n return { raw, creationTime, fbclid };\n}\n\n/**\n * Build a fresh `_fbc` value. subdomainIndex is the cookie's domain level (com=0, example.com=1,\n * www.example.com=2); 1 is correct for an apex-hosted cookie.\n */\nexport function formatFbc(fbclid: string, now: number, subdomainIndex = 1): string {\n return `fb.${subdomainIndex}.${now}.${fbclid}`;\n}\n\nexport type ParsedGcl = { raw: string; creationTime: number; clickId: string };\n\n// gtag's own value validator, extracted from gtag.js: the click id segment must match this.\nconst GCL_CLICK_ID = /^[\\w-]+$/;\n\n/**\n * Parse a `_gcl_aw`/`_gcl_gb` value: `GCL.<seconds>.<clickId>[.<labels…>]` — note the timestamp\n * is in SECONDS, unlike `_fbc`'s milliseconds. gtag's parser accepts version `GCL` or `1` and\n * validates the segments exactly this way (`/^\\d+$/` timestamp, `/^[\\w-]+$/` click id); any\n * labels tail is preserved through `raw` so a re-issue stays byte-identical.\n */\nexport function parseGcl(\n raw: string | undefined | null,\n now: number = Date.now()\n): ParsedGcl | undefined {\n if (!raw) return undefined;\n const parts = raw.split('.');\n if (parts.length < 3) return undefined;\n if (parts[0] !== 'GCL' && parts[0] !== '1') return undefined;\n const seconds = parts[1];\n const clickId = parts[2];\n if (seconds === undefined || !/^\\d+$/.test(seconds)) return undefined;\n if (clickId === undefined || !GCL_CLICK_ID.test(clickId)) return undefined;\n const creationTime = Number(seconds) * 1000;\n // Reject millisecond-precision or future-dated values as malformed.\n if (creationTime >= 1e15 || creationTime > now + CLOCK_SKEW_MS) return undefined;\n return { raw, creationTime, clickId };\n}\n\n/** Build a fresh `_gcl_aw`/`_gcl_gb` value in gtag's own format. */\nexport function formatGcl(clickId: string, now: number): string {\n return `GCL.${Math.floor(now / 1000)}.${clickId}`;\n}\n\nexport type ResolveClickIdCookiesInput = {\n /** The absolute request URL (must include the query string). */\n url: string;\n /** The raw `Cookie` request header, if any. */\n cookieHeader?: string | null;\n /** Overridable clock, primarily for tests. Defaults to `Date.now()`. */\n now?: number;\n /** `Domain` attribute for the emitted cookies, e.g. `.shware.io`. Omit for a host-only cookie. */\n domain?: string;\n /** `Secure` attribute, default true. Set false only for local http testing. */\n secure?: boolean;\n /** subdomainIndex for a freshly built `_fbc` (see {@link formatFbc}). Default 1. */\n subdomainIndex?: number;\n /**\n * Re-issue a still-valid `_fbc` at its *remaining* lifetime on every call. On by default, as an\n * ITP self-heal: cookies follow last-writer-wins, so if the Meta Pixel overwrote `_fbc` via\n * `document.cookie` (Safari caps JS-written cookies on a fbclid-decorated landing to 24h),\n * re-issuing the long-lived HTTP cookie on the next navigation restores it — the same\n * continuous-re-issue mitigation the sGTM ecosystem uses (stape Cookie Keeper, Cookie Monster).\n * The value (and its creationTime) is byte-for-byte identical and the window never slides, so it\n * cannot trigger Meta's expired/modified-fbclid warnings.\n *\n * Set false to strictly follow Meta's documented conditional-write rule (\"only set the cookie if\n * it doesn't exist or the fbclid changed\") — e.g. to keep pages CDN-cacheable, since the\n * re-issue attaches a per-user `Set-Cookie` (forcing `no-store`) to every response carrying an\n * `_fbc`.\n */\n refresh?: boolean;\n};\n\nexport type ResolveClickIdCookiesResult = {\n /**\n * Cookies to emit on the response, ready for `Set-Cookie`. Empty when nothing needs to change.\n * A `value: ''`, `maxAge: 0` entry is a deletion (an expired or malformed leftover).\n */\n cookies: SetCookie[];\n /** The resolved `_fbc` value, for immediate server-side use (e.g. Conversions API). */\n fbc?: string;\n /** The resolved `_rdt_cid` value. */\n rdt_cid?: string;\n /** The resolved Google Ads click id (from the URL or a still-valid `_gcl_aw`). */\n gclid?: string;\n /** The resolved iOS web-to-app click id (from the URL or a still-valid `_gcl_gb`). */\n wbraid?: string;\n};\n\nfunction searchParams(url: string): URLSearchParams {\n try {\n return new URL(url).searchParams;\n } catch {\n // Fall back to a manual split so a relative or slightly malformed URL still yields the query.\n const q = url.indexOf('?');\n return new URLSearchParams(q !== -1 ? url.slice(q + 1) : '');\n }\n}\n\n/**\n * Resolve the click-id cookies to set on the current document response.\n *\n * `_fbc` follows Meta's documented conditional-write rule value-wise: a new fbclid (or an absent\n * cookie) opens a fresh 90-day window; a same-fbclid cookie keeps its original value and\n * `creationTime`. Expired (>90d) or malformed values are cleared instead of forwarded. By default,\n * ({@link ResolveClickIdCookiesInput.refresh}) a still-valid cookie is re-issued unchanged at its\n * *remaining* lifetime — never `now + 90d` — so a returning visitor's window cannot slide forward\n * (which is what makes Meta flag an expired fbclid).\n */\nexport function resolveClickIdCookies(\n input: ResolveClickIdCookiesInput\n): ResolveClickIdCookiesResult {\n const { url, cookieHeader, domain, secure = true, subdomainIndex = 1, refresh = true } = input;\n const now = input.now ?? Date.now();\n\n const params = searchParams(url);\n const jar = parseCookie(cookieHeader ?? '');\n const cookies: SetCookie[] = [];\n const result: ResolveClickIdCookiesResult = { cookies };\n\n const base = { path: '/', secure, sameSite: 'lax', domain } as const;\n const set = (name: string, value: string, ttlMs: number) =>\n cookies.push({ name, value, maxAge: Math.floor(ttlMs / 1000), ...base });\n const del = (name: string) => cookies.push({ name, value: '', maxAge: 0, ...base });\n\n // --- Meta _fbc ---\n const urlFbclid = params.get('fbclid') || undefined;\n const existingFbc = parseFbc(jar[FBC_COOKIE], now);\n\n if (urlFbclid && urlFbclid !== existingFbc?.fbclid) {\n // A new click always wins and opens a fresh 90-day window.\n const raw = formatFbc(urlFbclid, now, subdomainIndex);\n set(FBC_COOKIE, raw, FBC_TTL_MS);\n result.fbc = raw;\n } else if (existingFbc) {\n const remainingMs = existingFbc.creationTime + FBC_TTL_MS - now;\n if (remainingMs <= 0) {\n del(FBC_COOKIE);\n } else {\n // Same fbclid: the value and creationTime are preserved (Meta's rule); refresh (default)\n // re-issues it unchanged at its remaining lifetime as an ITP self-heal.\n result.fbc = existingFbc.raw;\n if (refresh) set(FBC_COOKIE, existingFbc.raw, remainingMs);\n }\n } else if (jar[FBC_COOKIE]) {\n // Malformed leftover — clear it rather than forwarding it to Meta.\n del(FBC_COOKIE);\n }\n\n // --- Google Ads _gcl_aw / _gcl_gb ---\n // The same first-party HTTP persistence Google's own server-side Conversion Linker performs\n // (it sets FPGCLAW via Set-Cookie for 90 days), applied to gtag's cookies the way the sGTM\n // ecosystem does (stape Cookie Keeper re-issues _gcl_* over HTTP). Ownership is shared with\n // gtag, so the rules are stricter than for _fbc: a value this module cannot parse is left\n // untouched — never rewritten, never deleted — and a re-issue is byte-identical (labels tail\n // included) at the window's REMAINING lifetime, so the 90-day click window never slides.\n const gclsrc = params.get('gclsrc') || undefined;\n const google = [\n {\n cookie: GCL_AW_COOKIE,\n // gtag's own gating, verbatim: a gclid enters _gcl_aw only when gclsrc is absent or\n // 'aw.ds' — 'ds'/'3p.ds' clicks are Search Ads 360's and belong to _gcl_dc.\n urlClickId: !gclsrc || gclsrc === 'aw.ds' ? params.get('gclid') || undefined : undefined,\n assign: (value: string) => (result.gclid = value),\n },\n {\n cookie: GCL_GB_COOKIE,\n urlClickId: params.get('wbraid') || undefined,\n assign: (value: string) => (result.wbraid = value),\n },\n ];\n for (const { cookie, urlClickId, assign } of google) {\n const existing = parseGcl(jar[cookie], now);\n if (urlClickId && GCL_CLICK_ID.test(urlClickId) && urlClickId !== existing?.clickId) {\n // A new click always wins and opens a fresh 90-day window, in gtag's exact format.\n set(cookie, formatGcl(urlClickId, now), GCL_TTL_MS);\n assign(urlClickId);\n } else if (existing) {\n const remainingMs = existing.creationTime + GCL_TTL_MS - now;\n if (remainingMs > 0) {\n assign(existing.clickId);\n if (refresh) set(cookie, existing.raw, remainingMs);\n }\n // Expired: not returned and not re-issued, but gtag owns the deletion.\n }\n }\n\n // --- Reddit _rdt_cid ---\n // No embedded timestamp, so it can only be anchored at first capture; set it once from the URL and\n // otherwise leave the existing cookie untouched (re-issuing would slide its window).\n const urlRdtCid = params.get('rdt_cid') || undefined;\n const existingRdtCid = jar[RDT_CID_COOKIE] || undefined;\n if (urlRdtCid && urlRdtCid !== existingRdtCid) {\n set(RDT_CID_COOKIE, urlRdtCid, RDT_CID_TTL_MS);\n result.rdt_cid = urlRdtCid;\n } else if (existingRdtCid) {\n result.rdt_cid = existingRdtCid;\n }\n\n return result;\n}\n\n/** Serialize the resolved cookies into `Set-Cookie` header values. */\nexport function toSetCookieHeaders(cookies: SetCookie[]): string[] {\n return cookies.map((cookie) => stringifySetCookie(cookie));\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/click-id/index.ts"],"sourcesContent":["import { type SetCookie, parseCookie, stringifySetCookie } from 'cookie';\n\n/**\n * Server-side resolution of ad-click-id cookies (`_fbc`, `_gcl_aw`/`_gcl_gb`, `_rdt_cid`,\n * `_uetmsclkid`) from the incoming request.\n *\n * This is the framework-agnostic core meant to run on the *document* response (e.g. TanStack Start\n * server middleware, Next middleware). Setting `_fbc` via an HTTP `Set-Cookie` header on the top\n * document — rather than `document.cookie` on the client — is what Meta officially recommends and is\n * the only reliable way to keep the cookie alive for 90 days in Safari: ITP caps JavaScript-set\n * cookies on a fbclid-decorated landing page to 24 hours, and the document response is never\n * classified as CNAME/IP cloaking (it is the reference the browser measures cloaking against).\n *\n * The Google pair follows the same architecture Google itself ships for server containers —\n * sGTM's Conversion Linker sets FPGCLAW via Set-Cookie for 90 days — and the sGTM ecosystem\n * productized for gtag's own cookies (stape Cookie Keeper re-issues `_gcl_*` over HTTP; vendor\n * case studies report 5–20% of Google Ads conversions recovered, up to +44% on the Safari\n * slice). One deployment caveat from WebKit's rules: the HTTP-cookie exemption holds only for\n * responses from the site's own infrastructure — a CNAME or third-party-IP host (a tagging\n * subdomain on someone else's cloud) gets its Set-Cookie silently capped to 7 days. This module\n * runs on the host's own document response, which is exactly the exempt case.\n *\n * references:\n * - https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc\n * - https://webkit.org/tracking-prevention/ (7d JS cookies, 24h on ad-decorated landings,\n * CNAME/third-party-IP HTTP cookies capped to 7d, same-origin HTTP cookies exempt)\n * - https://www.simoahava.com/analytics/google-ads-server-side-tagging-google-tag-manager/\n * (Conversion Linker → FPGCLAW via Set-Cookie, Google's own server-side equivalent)\n * - https://stape.io/helpdesk/documentation/cookie-keeper-power-up (the productized re-issue)\n */\n\n// Meta's recommended _fbc cookie expiry. Events Manager warns (\"Server sending expired fbclid\")\n// when the embedded creationTime is older than 90 days and match quality/attribution may degrade;\n// no hard drop is documented.\nconst FBC_TTL_MS = 90 * 24 * 60 * 60 * 1000;\nconst RDT_CID_TTL_MS = 90 * 24 * 60 * 60 * 1000;\n// bat.js's own expiry for _uetmsclkid (`msClkIdExpirationTime`), the Microsoft Ads click window.\nconst MSCLKID_TTL_MS = 90 * 24 * 60 * 60 * 1000;\n// gtag's own expiry for _gcl_* — 90 days, the length of a Google Ads click's upload window.\nconst GCL_TTL_MS = 90 * 24 * 60 * 60 * 1000;\n// Tolerate a little clock skew when validating a creationTime against \"now\".\nconst CLOCK_SKEW_MS = 24 * 60 * 60 * 1000;\n\nexport const FBC_COOKIE = '_fbc';\nexport const RDT_CID_COOKIE = '_rdt_cid';\nexport const GCL_AW_COOKIE = '_gcl_aw';\nexport const GCL_GB_COOKIE = '_gcl_gb';\nexport const UET_MSCLKID_COOKIE = '_uetmsclkid';\n\nexport type ParsedFbc = { raw: string; creationTime: number; fbclid: string };\n\n/**\n * Parse `fb.<subdomainIndex>.<creationTime>.<fbclid>`.\n * Returns undefined for anything malformed — a fbclid never contains a dot, but joining the tail\n * back together keeps us forward-compatible if that ever changes.\n */\nexport function parseFbc(\n raw: string | undefined | null,\n now: number = Date.now()\n): ParsedFbc | undefined {\n if (!raw) return undefined;\n const parts = raw.split('.');\n if (parts.length < 4 || parts[0] !== 'fb') return undefined;\n\n const creationTime = Number(parts[2]);\n const fbclid = parts.slice(3).join('.');\n if (!fbclid) return undefined;\n // creationTime is UNIX ms; reject seconds-precision or future-dated values as malformed.\n if (!Number.isFinite(creationTime)) return undefined;\n if (creationTime < 1e12 || creationTime > now + CLOCK_SKEW_MS) return undefined;\n\n return { raw, creationTime, fbclid };\n}\n\n/**\n * Build a fresh `_fbc` value. subdomainIndex is the cookie's domain level (com=0, example.com=1,\n * www.example.com=2); 1 is correct for an apex-hosted cookie.\n */\nexport function formatFbc(fbclid: string, now: number, subdomainIndex = 1): string {\n return `fb.${subdomainIndex}.${now}.${fbclid}`;\n}\n\nexport type ParsedGcl = { raw: string; creationTime: number; clickId: string };\n\n// gtag's own value validator, extracted from the live gtag.js bundle (2026-09): its parser\n// accepts version \"GCL\" or \"1\", a /^\\d+$/ seconds timestamp, and a click id matching this.\n// Re-verify against gtag.js before changing anything here — the format is gtag's, not ours.\nconst GCL_CLICK_ID = /^[\\w-]+$/;\n\n/**\n * Parse a `_gcl_aw`/`_gcl_gb` value: `GCL.<seconds>.<clickId>[.<labels…>]` — note the timestamp\n * is in SECONDS, unlike `_fbc`'s milliseconds. gtag's parser accepts version `GCL` or `1` and\n * validates the segments exactly this way (`/^\\d+$/` timestamp, `/^[\\w-]+$/` click id); any\n * labels tail is preserved through `raw` so a re-issue stays byte-identical.\n */\nexport function parseGcl(\n raw: string | undefined | null,\n now: number = Date.now()\n): ParsedGcl | undefined {\n if (!raw) return undefined;\n const parts = raw.split('.');\n if (parts.length < 3) return undefined;\n if (parts[0] !== 'GCL' && parts[0] !== '1') return undefined;\n const seconds = parts[1];\n const clickId = parts[2];\n if (seconds === undefined || !/^\\d+$/.test(seconds)) return undefined;\n if (clickId === undefined || !GCL_CLICK_ID.test(clickId)) return undefined;\n const creationTime = Number(seconds) * 1000;\n // Reject millisecond-precision or future-dated values as malformed.\n if (creationTime >= 1e15 || creationTime > now + CLOCK_SKEW_MS) return undefined;\n return { raw, creationTime, clickId };\n}\n\n/** Build a fresh `_gcl_aw`/`_gcl_gb` value in gtag's own format. */\nexport function formatGcl(clickId: string, now: number): string {\n return `GCL.${Math.floor(now / 1000)}.${clickId}`;\n}\n\n// bat.js writes the click id as `_uet<msclkid>` and reads it back only when what follows the\n// prefix is at most 32 characters (`msClkIdCookieValuePrefix`, `lengthMsClkId`); a msclkid is a\n// 32-hex-digit UUID without dashes. Re-verify against bat.js before changing anything here —\n// the format is UET's, not ours.\nconst UET_MSCLKID_PREFIX = '_uet';\nconst MSCLKID = /^[0-9a-f]{32}$/i;\n\n/**\n * Parse a `_uetmsclkid` value: `_uet<32 hex>`. Returns the click id, lowercased, or undefined\n * for anything bat.js itself would not read back.\n */\nexport function parseUetMsclkid(raw: string | undefined | null): string | undefined {\n if (!raw || !raw.startsWith(UET_MSCLKID_PREFIX)) return undefined;\n const clickId = raw.slice(UET_MSCLKID_PREFIX.length);\n return MSCLKID.test(clickId) ? clickId.toLowerCase() : undefined;\n}\n\n/** Build a `_uetmsclkid` value in bat.js's own format. */\nexport function formatUetMsclkid(clickId: string): string {\n return `${UET_MSCLKID_PREFIX}${clickId.toLowerCase()}`;\n}\n\n/**\n * The click id as the Conversions API wants it: a dashed, lowercase UUID. The URL and the cookie\n * carry the 32-hex form; a value in any other shape is returned untouched for the API to judge.\n */\nexport function formatMsclkid(clickId: string): string {\n const id = clickId.trim().toLowerCase();\n if (!MSCLKID.test(id)) return id;\n return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;\n}\n\nexport type ResolveClickIdCookiesInput = {\n /** The absolute request URL (must include the query string). */\n url: string;\n /** The raw `Cookie` request header, if any. */\n cookieHeader?: string | null;\n /** Overridable clock, primarily for tests. Defaults to `Date.now()`. */\n now?: number;\n /** `Domain` attribute for the emitted cookies, e.g. `.shware.io`. Omit for a host-only cookie. */\n domain?: string;\n /** `Secure` attribute, default true. Set false only for local http testing. */\n secure?: boolean;\n /** subdomainIndex for a freshly built `_fbc` (see {@link formatFbc}). Default 1. */\n subdomainIndex?: number;\n /**\n * Re-issue a still-valid `_fbc` at its *remaining* lifetime on every call. On by default, as an\n * ITP self-heal: cookies follow last-writer-wins, so if the Meta Pixel overwrote `_fbc` via\n * `document.cookie` (Safari caps JS-written cookies on a fbclid-decorated landing to 24h),\n * re-issuing the long-lived HTTP cookie on the next navigation restores it — the same\n * continuous-re-issue mitigation the sGTM ecosystem uses (stape Cookie Keeper, Cookie Monster).\n * The value (and its creationTime) is byte-for-byte identical and the window never slides, so it\n * cannot trigger Meta's expired/modified-fbclid warnings.\n *\n * Set false to strictly follow Meta's documented conditional-write rule (\"only set the cookie if\n * it doesn't exist or the fbclid changed\") — e.g. to keep pages CDN-cacheable, since the\n * re-issue attaches a per-user `Set-Cookie` (forcing `no-store`) to every response carrying an\n * `_fbc`.\n */\n refresh?: boolean;\n};\n\nexport type ResolveClickIdCookiesResult = {\n /**\n * Cookies to emit on the response, ready for `Set-Cookie`. Empty when nothing needs to change.\n * A `value: ''`, `maxAge: 0` entry is a deletion (an expired or malformed leftover).\n */\n cookies: SetCookie[];\n /** The resolved `_fbc` value, for immediate server-side use (e.g. Conversions API). */\n fbc?: string;\n /** The resolved `_rdt_cid` value. */\n rdt_cid?: string;\n /** The resolved Google Ads click id (from the URL or a still-valid `_gcl_aw`). */\n gclid?: string;\n /** The resolved iOS web-to-app click id (from the URL or a still-valid `_gcl_gb`). */\n wbraid?: string;\n /** The resolved Microsoft Ads click id (from the URL or `_uetmsclkid`), 32-hex form. */\n msclkid?: string;\n};\n\nfunction searchParams(url: string): URLSearchParams {\n try {\n return new URL(url).searchParams;\n } catch {\n // Fall back to a manual split so a relative or slightly malformed URL still yields the query.\n const q = url.indexOf('?');\n return new URLSearchParams(q !== -1 ? url.slice(q + 1) : '');\n }\n}\n\n/**\n * Resolve the click-id cookies to set on the current document response.\n *\n * `_fbc` follows Meta's documented conditional-write rule value-wise: a new fbclid (or an absent\n * cookie) opens a fresh 90-day window; a same-fbclid cookie keeps its original value and\n * `creationTime`. Expired (>90d) or malformed values are cleared instead of forwarded. By default,\n * ({@link ResolveClickIdCookiesInput.refresh}) a still-valid cookie is re-issued unchanged at its\n * *remaining* lifetime — never `now + 90d` — so a returning visitor's window cannot slide forward\n * (which is what makes Meta flag an expired fbclid).\n */\nexport function resolveClickIdCookies(\n input: ResolveClickIdCookiesInput\n): ResolveClickIdCookiesResult {\n const { url, cookieHeader, domain, secure = true, subdomainIndex = 1, refresh = true } = input;\n const now = input.now ?? Date.now();\n\n const params = searchParams(url);\n const jar = parseCookie(cookieHeader ?? '');\n const cookies: SetCookie[] = [];\n const result: ResolveClickIdCookiesResult = { cookies };\n\n const base = { path: '/', secure, sameSite: 'lax', domain } as const;\n const set = (name: string, value: string, ttlMs: number) =>\n cookies.push({ name, value, maxAge: Math.floor(ttlMs / 1000), ...base });\n const del = (name: string) => cookies.push({ name, value: '', maxAge: 0, ...base });\n\n // --- Meta _fbc ---\n const urlFbclid = params.get('fbclid') || undefined;\n const existingFbc = parseFbc(jar[FBC_COOKIE], now);\n\n if (urlFbclid && urlFbclid !== existingFbc?.fbclid) {\n // A new click always wins and opens a fresh 90-day window.\n const raw = formatFbc(urlFbclid, now, subdomainIndex);\n set(FBC_COOKIE, raw, FBC_TTL_MS);\n result.fbc = raw;\n } else if (existingFbc) {\n const remainingMs = existingFbc.creationTime + FBC_TTL_MS - now;\n if (remainingMs <= 0) {\n del(FBC_COOKIE);\n } else {\n // Same fbclid: the value and creationTime are preserved (Meta's rule); refresh (default)\n // re-issues it unchanged at its remaining lifetime as an ITP self-heal.\n result.fbc = existingFbc.raw;\n if (refresh) set(FBC_COOKIE, existingFbc.raw, remainingMs);\n }\n } else if (jar[FBC_COOKIE]) {\n // Malformed leftover — clear it rather than forwarding it to Meta.\n del(FBC_COOKIE);\n }\n\n // --- Google Ads _gcl_aw / _gcl_gb ---\n // The same first-party HTTP persistence Google's own server-side Conversion Linker performs\n // (it sets FPGCLAW via Set-Cookie for 90 days), applied to gtag's cookies the way the sGTM\n // ecosystem does (stape Cookie Keeper re-issues _gcl_* over HTTP). Ownership is shared with\n // gtag, so the rules are stricter than for _fbc: a value this module cannot parse is left\n // untouched — never rewritten, never deleted — and a re-issue is byte-identical (labels tail\n // included) at the window's REMAINING lifetime, so the 90-day click window never slides.\n const gclsrc = params.get('gclsrc') || undefined;\n const google = [\n {\n cookie: GCL_AW_COOKIE,\n // gtag's own gating, verbatim: a gclid enters _gcl_aw only when gclsrc is absent or\n // 'aw.ds' — 'ds'/'3p.ds' clicks are Search Ads 360's and belong to _gcl_dc.\n urlClickId: !gclsrc || gclsrc === 'aw.ds' ? params.get('gclid') || undefined : undefined,\n assign: (value: string) => (result.gclid = value),\n },\n {\n cookie: GCL_GB_COOKIE,\n urlClickId: params.get('wbraid') || undefined,\n assign: (value: string) => (result.wbraid = value),\n },\n ];\n for (const { cookie, urlClickId, assign } of google) {\n const existing = parseGcl(jar[cookie], now);\n if (urlClickId && GCL_CLICK_ID.test(urlClickId) && urlClickId !== existing?.clickId) {\n // A new click always wins and opens a fresh 90-day window, in gtag's exact format.\n set(cookie, formatGcl(urlClickId, now), GCL_TTL_MS);\n assign(urlClickId);\n } else if (existing) {\n const remainingMs = existing.creationTime + GCL_TTL_MS - now;\n if (remainingMs > 0) {\n assign(existing.clickId);\n if (refresh) set(cookie, existing.raw, remainingMs);\n }\n // Expired: not returned and not re-issued, but gtag owns the deletion.\n }\n }\n\n // --- Reddit _rdt_cid ---\n // No embedded timestamp, so it can only be anchored at first capture; set it once from the URL and\n // otherwise leave the existing cookie untouched (re-issuing would slide its window).\n const urlRdtCid = params.get('rdt_cid') || undefined;\n const existingRdtCid = jar[RDT_CID_COOKIE] || undefined;\n if (urlRdtCid && urlRdtCid !== existingRdtCid) {\n set(RDT_CID_COOKIE, urlRdtCid, RDT_CID_TTL_MS);\n result.rdt_cid = urlRdtCid;\n } else if (existingRdtCid) {\n result.rdt_cid = existingRdtCid;\n }\n\n // --- Microsoft Ads _uetmsclkid ---\n // The UET tag's own cookie, in its own format, so the tag and this module read each other's\n // writes. Microsoft's ITP answer is this first-party cookie (plus a localStorage backup), but\n // bat.js writes it through `document.cookie`, which Safari caps at 7 days — 24 hours on a\n // landing page decorated by a classified domain, and a bing.com ad click is exactly that.\n // Persisting it here over HTTP on the document response restores the 90-day window.\n //\n // The re-issue (default on, see `refresh`) is the same self-heal as for `_fbc`, and it matters\n // more here: bat.js rewrites this cookie on EVERY page load, so without the re-issue the HTTP\n // copy is downgraded to a JS cookie on the very next page. There is no embedded timestamp to\n // compute a remaining lifetime from, so it is re-issued at a fresh 90 days — which is exactly\n // what bat.js itself does on each page (`msClkIdExpirationTime` from now), so the window\n // slides no more than the tag already slides it. A value this module cannot parse belongs to\n // the tag: never rewritten, never deleted.\n const urlMsclkid = params.get('msclkid')?.trim();\n const existingMsclkid = parseUetMsclkid(jar[UET_MSCLKID_COOKIE]);\n if (urlMsclkid && MSCLKID.test(urlMsclkid) && urlMsclkid.toLowerCase() !== existingMsclkid) {\n set(UET_MSCLKID_COOKIE, formatUetMsclkid(urlMsclkid), MSCLKID_TTL_MS);\n result.msclkid = urlMsclkid.toLowerCase();\n } else if (existingMsclkid) {\n result.msclkid = existingMsclkid;\n if (refresh) set(UET_MSCLKID_COOKIE, formatUetMsclkid(existingMsclkid), MSCLKID_TTL_MS);\n }\n\n return result;\n}\n\n/** Serialize the resolved cookies into `Set-Cookie` header values. */\nexport function toSetCookieHeaders(cookies: SetCookie[]): string[] {\n return cookies.map((cookie) => stringifySetCookie(cookie));\n}\n"],"mappings":"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"}
@@ -4,6 +4,7 @@ const require_constants_storage = require("../constants/storage.cjs");
4
4
  const require_setup_session = require("../setup/session.cjs");
5
5
  const require_track_index = require("../track/index.cjs");
6
6
  const require_hooks_use_previous = require("./use-previous.cjs");
7
+ const require_web_page_key = require("../web/page-key.cjs");
7
8
  let _shware_utils = require("@shware/utils");
8
9
  let react = require("react");
9
10
  //#region src/hooks/use-web-analytics.ts
@@ -51,13 +52,18 @@ function onVisibilityChange() {
51
52
  * 1. send session_start event when the page is loaded
52
53
  * 2. send scroll event when the user scrolls more than 90% of the page
53
54
  * 3. send user_engagement event when the page is hidden or the user is not focused
55
+ * 4. send page_view when the page changes — path or query, see `getPageKey`; `search` is the
56
+ * router's query string (`?a=1`, or `''`), required so no caller forgets that the query is
57
+ * part of what makes a page
54
58
  */
55
- function useWebAnalytics(pathname) {
59
+ function useWebAnalytics(pathname, search) {
60
+ const page = require_web_page_key.getPageKey(pathname, search);
61
+ const prevPage = require_hooks_use_previous.usePrevious(page);
56
62
  const prevPathname = require_hooks_use_previous.usePrevious(pathname);
57
63
  const hasSendScroll = (0, react.useRef)(false);
58
64
  (0, react.useEffect)(() => {
59
65
  hasSendScroll.current = false;
60
- }, [pathname]);
66
+ }, [page]);
61
67
  (0, react.useEffect)(() => {
62
68
  const session = require_setup_session.getSession();
63
69
  sendFirstVisit(pathname);
@@ -103,10 +109,10 @@ function useWebAnalytics(pathname) {
103
109
  page_title: document.title,
104
110
  page_referrer: document.referrer,
105
111
  page_location: window.location.href,
106
- previous_page_path: prevPathname ?? void 0,
107
- engagement_time_msec: prevPathname ? require_setup_session.getSession().flush() : void 0
112
+ previous_page_path: prevPage === void 0 ? void 0 : prevPathname,
113
+ engagement_time_msec: prevPage === void 0 ? void 0 : require_setup_session.getSession().flush()
108
114
  });
109
- }, [pathname]);
115
+ }, [page]);
110
116
  }
111
117
  //#endregion
112
118
  exports.useWebAnalytics = useWebAnalytics;