@callforge/tracking-client 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @callforge/tracking-client
2
2
 
3
- Lightweight client library for the CallForge tracking API. Handles location-aware phone number assignment with aggressive caching and optional preload optimization.
3
+ Lightweight client library for the CallForge tracking API. Handles location-aware phone number assignment with aggressive caching and preload optimization.
4
4
 
5
5
  ## Installation
6
6
 
@@ -10,11 +10,20 @@ npm install @callforge/tracking-client
10
10
  pnpm add @callforge/tracking-client
11
11
  ```
12
12
 
13
+ ## Lease Hardening Migration (v0.8+)
14
+
15
+ This release adds bootstrap-token support for lease hardening and bot suppression.
16
+
17
+ Client integration requirements:
18
+ - Add the generated preload snippet in `<head>`. This prefetches bootstrap tokens and keeps session lookup fast.
19
+ - Keep handling `phoneNumber` and `leaseId` separately. A request can return a phone number with `leaseId: null` when lease assignment is intentionally suppressed.
20
+ - For attribution/scale metrics, treat `leaseId` as the source of truth for lease-backed traffic.
21
+
13
22
  ## Quick Start
14
23
 
15
- ### 1. Add preload snippet to `<head>` (optional but recommended)
24
+ ### 1. Add preload snippet to `<head>` (required for deterministic leases)
16
25
 
17
- For optimal performance on static sites, add this snippet to your HTML `<head>`:
26
+ For optimal performance and lease hardening, add this snippet to your HTML `<head>`:
18
27
 
19
28
  ```typescript
20
29
  import { getPreloadSnippet } from '@callforge/tracking-client';
@@ -43,7 +52,13 @@ const client = CallForge.init({
43
52
  const { session, location } = client.getSessionAsync();
44
53
 
45
54
  // Location is delivered independently (often faster than phone number assignment)
46
- console.log(await location); // { city: "Woodstock", state: "Georgia", stateCode: "GA" } or null
55
+ console.log(await location);
56
+ // {
57
+ // city: "Woodstock",
58
+ // state: "Georgia",
59
+ // stateCode: "GA",
60
+ // zipOptions: ["30188", "30189", "30066", ...] // may be []
61
+ // } or null
47
62
 
48
63
  // Phone session data (deterministic token + phone number)
49
64
  console.log(await session); // { sessionToken, leaseId, phoneNumber }
@@ -152,6 +167,7 @@ Behavior:
152
167
  - Returns cached data if valid.
153
168
  - Fetches fresh data when cache is missing/expired.
154
169
  - If `loc_physical_ms` is present in the URL, cached sessions are only reused when it matches the cached `locId`.
170
+ - If lease assignment is suppressed (for example bot traffic or missing/invalid bootstrap), `phoneNumber` may be present while `leaseId` is `null`.
155
171
  - Throws on network errors or API errors.
156
172
 
157
173
  ### `client.getLocation()`
@@ -160,7 +176,16 @@ Get location data only. Returns cached data if valid, otherwise fetches from the
160
176
 
161
177
  ```typescript
162
178
  const location = await client.getLocation();
163
- // { city, state, stateCode } or null
179
+ // { city, state, stateCode, zipOptions } or null
180
+ ```
181
+
182
+ ```typescript
183
+ interface TrackingLocation {
184
+ city: string;
185
+ state: string;
186
+ stateCode: string;
187
+ zipOptions?: string[]; // ordered by proximity, may be []
188
+ }
164
189
  ```
165
190
 
166
191
  ### `client.getSessionAsync()`
@@ -172,6 +197,7 @@ const { session, location } = client.getSessionAsync();
172
197
 
173
198
  location.then((loc) => {
174
199
  // show city/state ASAP
200
+ // optionally render loc?.zipOptions in a ZIP picker
175
201
  });
176
202
 
177
203
  session.then((sess) => {
@@ -204,7 +230,7 @@ Subscribe to location ready event. Callback is called once location data is avai
204
230
 
205
231
  ```typescript
206
232
  client.onLocationReady((location) => {
207
- // location is { city, state, stateCode } or null
233
+ // location is { city, state, stateCode, zipOptions } or null
208
234
  });
209
235
  ```
210
236
 
@@ -266,6 +292,7 @@ Parameters are sent as a sorted query string for cache consistency:
266
292
 
267
293
  - Session cache key: `cf_tracking_v1_<siteKey>_<categoryId>`
268
294
  - Location cache key: `cf_location_v1_<siteKey>`
295
+ - Bootstrap cache key: `cf_bootstrap_v1_<siteKey>_<categoryId>`
269
296
  - TTL: controlled by the server `expiresAt` response (currently 30 minutes)
270
297
  - Storage: localStorage (falls back to memory if unavailable)
271
298
 
package/dist/index.d.mts CHANGED
@@ -21,6 +21,8 @@ interface TrackingLocation {
21
21
  state: string;
22
22
  /** State abbreviation (e.g., "GA") */
23
23
  stateCode: string;
24
+ /** Suggested ZIPs ordered by proximity (may be empty) */
25
+ zipOptions?: string[];
24
26
  }
25
27
  type TrackingLocationSource = 'google_criteria' | 'cloudflare_geo';
26
28
  /**
@@ -70,6 +72,7 @@ interface ApiLocationResponse {
70
72
  stateCode: string;
71
73
  source: TrackingLocationSource;
72
74
  } | null;
75
+ zipOptions?: string[];
73
76
  expiresAt: number;
74
77
  }
75
78
  interface CallIntentResponse {
@@ -100,6 +103,8 @@ declare class CallForge {
100
103
  private readonly config;
101
104
  private readonly cache;
102
105
  private readonly locationCache;
106
+ private readonly bootstrapCacheKey;
107
+ private bootstrapMemoryCache;
103
108
  private sessionPromise;
104
109
  private locationPromise;
105
110
  private customParams;
@@ -171,13 +176,19 @@ declare class CallForge {
171
176
  private getLocationId;
172
177
  private getAutoParams;
173
178
  private fetchFromApi;
179
+ private getBootstrapToken;
174
180
  private fetchLocationFromApi;
181
+ private getCachedBootstrapToken;
182
+ private saveBootstrapToken;
175
183
  private saveToCache;
176
184
  private saveLocationToCache;
185
+ private toTrackingLocation;
186
+ private normalizeZipOptions;
177
187
  private formatSession;
178
188
  private formatApiResponse;
179
189
  private syncParamsToCallForgeIfPossible;
180
190
  private buildUrl;
191
+ private buildBootstrapUrl;
181
192
  private buildLocationUrl;
182
193
  }
183
194
 
package/dist/index.d.ts CHANGED
@@ -21,6 +21,8 @@ interface TrackingLocation {
21
21
  state: string;
22
22
  /** State abbreviation (e.g., "GA") */
23
23
  stateCode: string;
24
+ /** Suggested ZIPs ordered by proximity (may be empty) */
25
+ zipOptions?: string[];
24
26
  }
25
27
  type TrackingLocationSource = 'google_criteria' | 'cloudflare_geo';
26
28
  /**
@@ -70,6 +72,7 @@ interface ApiLocationResponse {
70
72
  stateCode: string;
71
73
  source: TrackingLocationSource;
72
74
  } | null;
75
+ zipOptions?: string[];
73
76
  expiresAt: number;
74
77
  }
75
78
  interface CallIntentResponse {
@@ -100,6 +103,8 @@ declare class CallForge {
100
103
  private readonly config;
101
104
  private readonly cache;
102
105
  private readonly locationCache;
106
+ private readonly bootstrapCacheKey;
107
+ private bootstrapMemoryCache;
103
108
  private sessionPromise;
104
109
  private locationPromise;
105
110
  private customParams;
@@ -171,13 +176,19 @@ declare class CallForge {
171
176
  private getLocationId;
172
177
  private getAutoParams;
173
178
  private fetchFromApi;
179
+ private getBootstrapToken;
174
180
  private fetchLocationFromApi;
181
+ private getCachedBootstrapToken;
182
+ private saveBootstrapToken;
175
183
  private saveToCache;
176
184
  private saveLocationToCache;
185
+ private toTrackingLocation;
186
+ private normalizeZipOptions;
177
187
  private formatSession;
178
188
  private formatApiResponse;
179
189
  private syncParamsToCallForgeIfPossible;
180
190
  private buildUrl;
191
+ private buildBootstrapUrl;
181
192
  private buildLocationUrl;
182
193
  }
183
194
 
package/dist/index.js CHANGED
@@ -203,9 +203,11 @@ var LocationCache = class {
203
203
  var DEFAULT_ENDPOINT = "https://tracking.callforge.io";
204
204
  var FETCH_TIMEOUT_MS = 1e4;
205
205
  var CALL_INTENT_TIMEOUT_MS = 8e3;
206
+ var BOOTSTRAP_TOKEN_EXPIRY_BUFFER_MS = 1e4;
206
207
  var AUTO_PARAMS = ["gclid", "gbraid", "wbraid", "msclkid", "fbclid", "gad_campaignid", "gad_source"];
207
208
  var CallForge = class _CallForge {
208
209
  constructor(config) {
210
+ this.bootstrapMemoryCache = null;
209
211
  this.sessionPromise = null;
210
212
  this.locationPromise = null;
211
213
  this.customParams = {};
@@ -215,8 +217,10 @@ var CallForge = class _CallForge {
215
217
  ga4MeasurementId: config.ga4MeasurementId,
216
218
  siteKey: config.siteKey
217
219
  };
220
+ const resolvedSiteKey = config.siteKey || (typeof window !== "undefined" ? window.location.hostname : "unknown-site");
218
221
  this.cache = new TrackingCache(config.categoryId, config.siteKey);
219
222
  this.locationCache = new LocationCache(config.siteKey);
223
+ this.bootstrapCacheKey = `cf_bootstrap_v1_${resolvedSiteKey}_${config.categoryId}`;
220
224
  this.captureGA4ClientId();
221
225
  this.startGA4ClientIdPolling();
222
226
  }
@@ -424,18 +428,14 @@ var CallForge = class _CallForge {
424
428
  return this.formatApiResponse(data);
425
429
  }
426
430
  async fetchLocation() {
427
- var _a;
431
+ var _a, _b;
428
432
  const locationId = this.getLocationId();
429
433
  if (typeof window !== "undefined" && window.__cfTrackingLocation) {
430
434
  try {
431
435
  const data2 = await window.__cfTrackingLocation;
432
436
  const dataWithExtras = data2;
433
437
  const effectiveLocId = (_a = dataWithExtras.locId) != null ? _a : locationId;
434
- const location2 = data2.location ? {
435
- city: data2.location.city,
436
- state: data2.location.state,
437
- stateCode: data2.location.stateCode
438
- } : null;
438
+ const location2 = this.toTrackingLocation(data2.location, dataWithExtras.zipOptions);
439
439
  this.saveLocationToCache(effectiveLocId, location2, data2.expiresAt);
440
440
  return location2;
441
441
  } catch (e) {
@@ -443,14 +443,10 @@ var CallForge = class _CallForge {
443
443
  }
444
444
  const cached = this.locationCache.get(locationId);
445
445
  if (cached) {
446
- return cached.location;
446
+ return this.toTrackingLocation(cached.location, (_b = cached.location) == null ? void 0 : _b.zipOptions);
447
447
  }
448
448
  const data = await this.fetchLocationFromApi(locationId);
449
- const location = data.location ? {
450
- city: data.location.city,
451
- state: data.location.state,
452
- stateCode: data.location.stateCode
453
- } : null;
449
+ const location = this.toTrackingLocation(data.location, data.zipOptions);
454
450
  this.saveLocationToCache(locationId, location, data.expiresAt);
455
451
  return location;
456
452
  }
@@ -472,7 +468,43 @@ var CallForge = class _CallForge {
472
468
  return params;
473
469
  }
474
470
  async fetchFromApi(locationId, sessionToken, params) {
475
- const url = this.buildUrl(locationId, sessionToken, params);
471
+ const controller = new AbortController();
472
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
473
+ try {
474
+ let bootstrapToken = this.getCachedBootstrapToken();
475
+ let response = await fetch(
476
+ this.buildUrl(locationId, sessionToken, params, bootstrapToken),
477
+ {
478
+ credentials: "omit",
479
+ signal: controller.signal
480
+ }
481
+ );
482
+ if (response.status === 401) {
483
+ bootstrapToken = await this.getBootstrapToken(true);
484
+ if (bootstrapToken) {
485
+ response = await fetch(
486
+ this.buildUrl(locationId, sessionToken, params, bootstrapToken),
487
+ {
488
+ credentials: "omit",
489
+ signal: controller.signal
490
+ }
491
+ );
492
+ }
493
+ }
494
+ if (!response.ok) {
495
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
496
+ }
497
+ return await response.json();
498
+ } finally {
499
+ clearTimeout(timeoutId);
500
+ }
501
+ }
502
+ async getBootstrapToken(forceRefresh = false) {
503
+ const cached = this.getCachedBootstrapToken();
504
+ if (!forceRefresh && cached) {
505
+ return cached;
506
+ }
507
+ const url = this.buildBootstrapUrl();
476
508
  const controller = new AbortController();
477
509
  const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
478
510
  try {
@@ -481,9 +513,14 @@ var CallForge = class _CallForge {
481
513
  signal: controller.signal
482
514
  });
483
515
  if (!response.ok) {
484
- throw new Error(`API error: ${response.status} ${response.statusText}`);
516
+ return null;
485
517
  }
486
- return await response.json();
518
+ const data = await response.json();
519
+ if (typeof data.bootstrapToken !== "string" || typeof data.expiresAt !== "number") {
520
+ return null;
521
+ }
522
+ this.saveBootstrapToken(data.bootstrapToken, data.expiresAt);
523
+ return data.bootstrapToken;
487
524
  } finally {
488
525
  clearTimeout(timeoutId);
489
526
  }
@@ -505,6 +542,40 @@ var CallForge = class _CallForge {
505
542
  clearTimeout(timeoutId);
506
543
  }
507
544
  }
545
+ getCachedBootstrapToken() {
546
+ var _a;
547
+ const now = Date.now();
548
+ const fromMemory = this.bootstrapMemoryCache;
549
+ if (fromMemory && fromMemory.expiresAt - BOOTSTRAP_TOKEN_EXPIRY_BUFFER_MS > now) {
550
+ return fromMemory.token;
551
+ }
552
+ try {
553
+ const raw = localStorage.getItem(this.bootstrapCacheKey);
554
+ if (!raw) return null;
555
+ const cached = JSON.parse(raw);
556
+ if (typeof cached.token !== "string" || typeof cached.expiresAt !== "number") {
557
+ return null;
558
+ }
559
+ if (cached.expiresAt - BOOTSTRAP_TOKEN_EXPIRY_BUFFER_MS <= now) {
560
+ return null;
561
+ }
562
+ return cached.token;
563
+ } catch (e) {
564
+ return (_a = fromMemory == null ? void 0 : fromMemory.token) != null ? _a : null;
565
+ }
566
+ }
567
+ saveBootstrapToken(token, expiresAt) {
568
+ const cached = {
569
+ token,
570
+ expiresAt,
571
+ tokenVersion: "b1"
572
+ };
573
+ this.bootstrapMemoryCache = cached;
574
+ try {
575
+ localStorage.setItem(this.bootstrapCacheKey, JSON.stringify(cached));
576
+ } catch (e) {
577
+ }
578
+ }
508
579
  saveToCache(locationId, data, params) {
509
580
  const cached = {
510
581
  locId: locationId != null ? locationId : null,
@@ -525,6 +596,27 @@ var CallForge = class _CallForge {
525
596
  tokenVersion: "v1"
526
597
  });
527
598
  }
599
+ toTrackingLocation(location, zipOptions) {
600
+ if (!location) {
601
+ return null;
602
+ }
603
+ return {
604
+ city: location.city,
605
+ state: location.state,
606
+ stateCode: location.stateCode,
607
+ zipOptions: this.normalizeZipOptions(zipOptions)
608
+ };
609
+ }
610
+ normalizeZipOptions(value) {
611
+ if (!Array.isArray(value)) {
612
+ return [];
613
+ }
614
+ return Array.from(
615
+ new Set(
616
+ value.filter((zip) => typeof zip === "string" && /^\d{5}$/.test(zip))
617
+ )
618
+ );
619
+ }
528
620
  formatSession(cached) {
529
621
  return {
530
622
  sessionToken: cached.sessionToken,
@@ -549,7 +641,7 @@ var CallForge = class _CallForge {
549
641
  const data = await this.fetchFromApi(locationId, sessionToken, params);
550
642
  this.saveToCache(locationId, data, params);
551
643
  }
552
- buildUrl(locationId, sessionToken, params) {
644
+ buildUrl(locationId, sessionToken, params, bootstrapToken) {
553
645
  const { categoryId, endpoint } = this.config;
554
646
  const queryParams = {
555
647
  categoryId
@@ -560,6 +652,9 @@ var CallForge = class _CallForge {
560
652
  if (sessionToken) {
561
653
  queryParams.sessionToken = sessionToken;
562
654
  }
655
+ if (bootstrapToken) {
656
+ queryParams.bootstrapToken = bootstrapToken;
657
+ }
563
658
  for (const [key, value] of Object.entries(params)) {
564
659
  if (value !== void 0) {
565
660
  queryParams[key] = value;
@@ -569,6 +664,10 @@ var CallForge = class _CallForge {
569
664
  const qs = sorted.map((k) => `${k}=${encodeURIComponent(queryParams[k])}`).join("&");
570
665
  return `${endpoint}/v1/tracking/session?${qs}`;
571
666
  }
667
+ buildBootstrapUrl() {
668
+ const { endpoint, categoryId } = this.config;
669
+ return `${endpoint}/v1/tracking/bootstrap?categoryId=${encodeURIComponent(categoryId)}`;
670
+ }
572
671
  buildLocationUrl(locationId) {
573
672
  const { endpoint } = this.config;
574
673
  if (!locationId) {
@@ -606,15 +705,16 @@ for(var i=0;i<ap.length;i++){var v=u.get(ap[i]);if(v)p[ap[i]]=v}
606
705
  var site='${config.siteKey || ""}'||location.hostname||'unknown-site';
607
706
  var key='cf_tracking_v1_'+site+'_${categoryId}';
608
707
  var lkey='cf_location_v1_'+site;
708
+ var bkey='cf_bootstrap_v1_'+site+'_${categoryId}';
609
709
  try{
610
710
  var cl=JSON.parse(localStorage.getItem(lkey));
611
711
  if(cl&&cl.expiresAt>Date.now()+30000){
612
- if(!loc||(loc&&cl.locId===loc)){window.__cfTrackingLocation=Promise.resolve(cl)}
712
+ if(!loc||(loc&&cl.locId===loc)){if(cl.location&&!Array.isArray(cl.location.zipOptions))cl.location.zipOptions=[];window.__cfTrackingLocation=Promise.resolve(cl)}
613
713
  }}catch(e){}
614
714
  if(!window.__cfTrackingLocation){
615
715
  var lurl='${endpoint}/v1/tracking/location';
616
716
  if(loc)lurl+='?loc_physical_ms='+encodeURIComponent(loc);
617
- window.__cfTrackingLocation=fetch(lurl,{credentials:'omit'}).then(function(r){if(!r.ok)throw new Error('tracking preload location failed');return r.json()}).then(function(d){d.locId=loc;try{localStorage.setItem(lkey,JSON.stringify({locId:loc,location:d.location?{city:d.location.city,state:d.location.state,stateCode:d.location.stateCode}:null,expiresAt:d.expiresAt,tokenVersion:'v1'}))}catch(e){}return d});
717
+ window.__cfTrackingLocation=fetch(lurl,{credentials:'omit'}).then(function(r){if(!r.ok)throw new Error('tracking preload location failed');return r.json()}).then(function(d){var z=Array.isArray(d.zipOptions)?d.zipOptions.filter(function(x){return /^\\d{5}$/.test(x)}):[];d.locId=loc;d.zipOptions=z;try{localStorage.setItem(lkey,JSON.stringify({locId:loc,location:d.location?{city:d.location.city,state:d.location.state,stateCode:d.location.stateCode,zipOptions:z}:null,expiresAt:d.expiresAt,tokenVersion:'v1'}))}catch(e){}return d});
618
718
  }
619
719
  var token=null;
620
720
  try{
@@ -625,12 +725,18 @@ token=(!loc||c.locId===loc)?c.sessionToken:null;
625
725
  var cp=c.params||{};
626
726
  p=Object.assign({},cp,p);
627
727
  }}catch(e){}
728
+ var bt=null;
729
+ try{
730
+ var cb=JSON.parse(localStorage.getItem(bkey));
731
+ if(cb&&typeof cb.token==='string'&&cb.expiresAt>Date.now()+10000)bt=cb.token;
732
+ }catch(e){}
733
+ var bp=bt?Promise.resolve({bootstrapToken:bt}):fetch('${endpoint}/v1/tracking/bootstrap?categoryId=${categoryId}',{credentials:'omit'}).then(function(r){if(!r.ok)return null;return r.json()}).then(function(b){if(!b||typeof b.bootstrapToken!=='string'||typeof b.expiresAt!=='number')return null;try{localStorage.setItem(bkey,JSON.stringify({token:b.bootstrapToken,expiresAt:b.expiresAt,tokenVersion:'b1'}))}catch(e){}return b}).catch(function(){return null});
628
734
  var url='${endpoint}/v1/tracking/session?categoryId=${categoryId}';
629
735
  if(loc)url+='&loc_physical_ms='+loc;
630
736
  if(token)url+='&sessionToken='+encodeURIComponent(token);
631
737
  var ks=Object.keys(p).sort();
632
738
  for(var j=0;j<ks.length;j++)url+='&'+ks[j]+'='+encodeURIComponent(p[ks[j]]);
633
- window.__cfTracking=fetch(url,{credentials:'omit'}).then(function(r){if(!r.ok)throw new Error('tracking preload failed');return r.json()}).then(function(d){d.params=p;d.locId=loc;try{localStorage.setItem(key,JSON.stringify({locId:loc,sessionToken:d.sessionToken,leaseId:d.leaseId,phoneNumber:d.phoneNumber,expiresAt:d.expiresAt,tokenVersion:'v1',params:p}))}catch(e){}return d});
739
+ window.__cfTracking=bp.then(function(b){if(b&&b.bootstrapToken)url+='&bootstrapToken='+encodeURIComponent(b.bootstrapToken);return fetch(url,{credentials:'omit'})}).then(function(r){if(!r.ok)throw new Error('tracking preload failed');return r.json()}).then(function(d){d.params=p;d.locId=loc;try{localStorage.setItem(key,JSON.stringify({locId:loc,sessionToken:d.sessionToken,leaseId:d.leaseId,phoneNumber:d.phoneNumber,expiresAt:d.expiresAt,tokenVersion:'v1',params:p}))}catch(e){}return d});
634
740
  })();`.replace(/\n/g, "");
635
741
  return `<link rel="preconnect" href="${endpoint}">
636
742
  <script>${script}</script>`;
package/dist/index.mjs CHANGED
@@ -179,9 +179,11 @@ var LocationCache = class {
179
179
  var DEFAULT_ENDPOINT = "https://tracking.callforge.io";
180
180
  var FETCH_TIMEOUT_MS = 1e4;
181
181
  var CALL_INTENT_TIMEOUT_MS = 8e3;
182
+ var BOOTSTRAP_TOKEN_EXPIRY_BUFFER_MS = 1e4;
182
183
  var AUTO_PARAMS = ["gclid", "gbraid", "wbraid", "msclkid", "fbclid", "gad_campaignid", "gad_source"];
183
184
  var CallForge = class _CallForge {
184
185
  constructor(config) {
186
+ this.bootstrapMemoryCache = null;
185
187
  this.sessionPromise = null;
186
188
  this.locationPromise = null;
187
189
  this.customParams = {};
@@ -191,8 +193,10 @@ var CallForge = class _CallForge {
191
193
  ga4MeasurementId: config.ga4MeasurementId,
192
194
  siteKey: config.siteKey
193
195
  };
196
+ const resolvedSiteKey = config.siteKey || (typeof window !== "undefined" ? window.location.hostname : "unknown-site");
194
197
  this.cache = new TrackingCache(config.categoryId, config.siteKey);
195
198
  this.locationCache = new LocationCache(config.siteKey);
199
+ this.bootstrapCacheKey = `cf_bootstrap_v1_${resolvedSiteKey}_${config.categoryId}`;
196
200
  this.captureGA4ClientId();
197
201
  this.startGA4ClientIdPolling();
198
202
  }
@@ -400,18 +404,14 @@ var CallForge = class _CallForge {
400
404
  return this.formatApiResponse(data);
401
405
  }
402
406
  async fetchLocation() {
403
- var _a;
407
+ var _a, _b;
404
408
  const locationId = this.getLocationId();
405
409
  if (typeof window !== "undefined" && window.__cfTrackingLocation) {
406
410
  try {
407
411
  const data2 = await window.__cfTrackingLocation;
408
412
  const dataWithExtras = data2;
409
413
  const effectiveLocId = (_a = dataWithExtras.locId) != null ? _a : locationId;
410
- const location2 = data2.location ? {
411
- city: data2.location.city,
412
- state: data2.location.state,
413
- stateCode: data2.location.stateCode
414
- } : null;
414
+ const location2 = this.toTrackingLocation(data2.location, dataWithExtras.zipOptions);
415
415
  this.saveLocationToCache(effectiveLocId, location2, data2.expiresAt);
416
416
  return location2;
417
417
  } catch (e) {
@@ -419,14 +419,10 @@ var CallForge = class _CallForge {
419
419
  }
420
420
  const cached = this.locationCache.get(locationId);
421
421
  if (cached) {
422
- return cached.location;
422
+ return this.toTrackingLocation(cached.location, (_b = cached.location) == null ? void 0 : _b.zipOptions);
423
423
  }
424
424
  const data = await this.fetchLocationFromApi(locationId);
425
- const location = data.location ? {
426
- city: data.location.city,
427
- state: data.location.state,
428
- stateCode: data.location.stateCode
429
- } : null;
425
+ const location = this.toTrackingLocation(data.location, data.zipOptions);
430
426
  this.saveLocationToCache(locationId, location, data.expiresAt);
431
427
  return location;
432
428
  }
@@ -448,7 +444,43 @@ var CallForge = class _CallForge {
448
444
  return params;
449
445
  }
450
446
  async fetchFromApi(locationId, sessionToken, params) {
451
- const url = this.buildUrl(locationId, sessionToken, params);
447
+ const controller = new AbortController();
448
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
449
+ try {
450
+ let bootstrapToken = this.getCachedBootstrapToken();
451
+ let response = await fetch(
452
+ this.buildUrl(locationId, sessionToken, params, bootstrapToken),
453
+ {
454
+ credentials: "omit",
455
+ signal: controller.signal
456
+ }
457
+ );
458
+ if (response.status === 401) {
459
+ bootstrapToken = await this.getBootstrapToken(true);
460
+ if (bootstrapToken) {
461
+ response = await fetch(
462
+ this.buildUrl(locationId, sessionToken, params, bootstrapToken),
463
+ {
464
+ credentials: "omit",
465
+ signal: controller.signal
466
+ }
467
+ );
468
+ }
469
+ }
470
+ if (!response.ok) {
471
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
472
+ }
473
+ return await response.json();
474
+ } finally {
475
+ clearTimeout(timeoutId);
476
+ }
477
+ }
478
+ async getBootstrapToken(forceRefresh = false) {
479
+ const cached = this.getCachedBootstrapToken();
480
+ if (!forceRefresh && cached) {
481
+ return cached;
482
+ }
483
+ const url = this.buildBootstrapUrl();
452
484
  const controller = new AbortController();
453
485
  const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
454
486
  try {
@@ -457,9 +489,14 @@ var CallForge = class _CallForge {
457
489
  signal: controller.signal
458
490
  });
459
491
  if (!response.ok) {
460
- throw new Error(`API error: ${response.status} ${response.statusText}`);
492
+ return null;
461
493
  }
462
- return await response.json();
494
+ const data = await response.json();
495
+ if (typeof data.bootstrapToken !== "string" || typeof data.expiresAt !== "number") {
496
+ return null;
497
+ }
498
+ this.saveBootstrapToken(data.bootstrapToken, data.expiresAt);
499
+ return data.bootstrapToken;
463
500
  } finally {
464
501
  clearTimeout(timeoutId);
465
502
  }
@@ -481,6 +518,40 @@ var CallForge = class _CallForge {
481
518
  clearTimeout(timeoutId);
482
519
  }
483
520
  }
521
+ getCachedBootstrapToken() {
522
+ var _a;
523
+ const now = Date.now();
524
+ const fromMemory = this.bootstrapMemoryCache;
525
+ if (fromMemory && fromMemory.expiresAt - BOOTSTRAP_TOKEN_EXPIRY_BUFFER_MS > now) {
526
+ return fromMemory.token;
527
+ }
528
+ try {
529
+ const raw = localStorage.getItem(this.bootstrapCacheKey);
530
+ if (!raw) return null;
531
+ const cached = JSON.parse(raw);
532
+ if (typeof cached.token !== "string" || typeof cached.expiresAt !== "number") {
533
+ return null;
534
+ }
535
+ if (cached.expiresAt - BOOTSTRAP_TOKEN_EXPIRY_BUFFER_MS <= now) {
536
+ return null;
537
+ }
538
+ return cached.token;
539
+ } catch (e) {
540
+ return (_a = fromMemory == null ? void 0 : fromMemory.token) != null ? _a : null;
541
+ }
542
+ }
543
+ saveBootstrapToken(token, expiresAt) {
544
+ const cached = {
545
+ token,
546
+ expiresAt,
547
+ tokenVersion: "b1"
548
+ };
549
+ this.bootstrapMemoryCache = cached;
550
+ try {
551
+ localStorage.setItem(this.bootstrapCacheKey, JSON.stringify(cached));
552
+ } catch (e) {
553
+ }
554
+ }
484
555
  saveToCache(locationId, data, params) {
485
556
  const cached = {
486
557
  locId: locationId != null ? locationId : null,
@@ -501,6 +572,27 @@ var CallForge = class _CallForge {
501
572
  tokenVersion: "v1"
502
573
  });
503
574
  }
575
+ toTrackingLocation(location, zipOptions) {
576
+ if (!location) {
577
+ return null;
578
+ }
579
+ return {
580
+ city: location.city,
581
+ state: location.state,
582
+ stateCode: location.stateCode,
583
+ zipOptions: this.normalizeZipOptions(zipOptions)
584
+ };
585
+ }
586
+ normalizeZipOptions(value) {
587
+ if (!Array.isArray(value)) {
588
+ return [];
589
+ }
590
+ return Array.from(
591
+ new Set(
592
+ value.filter((zip) => typeof zip === "string" && /^\d{5}$/.test(zip))
593
+ )
594
+ );
595
+ }
504
596
  formatSession(cached) {
505
597
  return {
506
598
  sessionToken: cached.sessionToken,
@@ -525,7 +617,7 @@ var CallForge = class _CallForge {
525
617
  const data = await this.fetchFromApi(locationId, sessionToken, params);
526
618
  this.saveToCache(locationId, data, params);
527
619
  }
528
- buildUrl(locationId, sessionToken, params) {
620
+ buildUrl(locationId, sessionToken, params, bootstrapToken) {
529
621
  const { categoryId, endpoint } = this.config;
530
622
  const queryParams = {
531
623
  categoryId
@@ -536,6 +628,9 @@ var CallForge = class _CallForge {
536
628
  if (sessionToken) {
537
629
  queryParams.sessionToken = sessionToken;
538
630
  }
631
+ if (bootstrapToken) {
632
+ queryParams.bootstrapToken = bootstrapToken;
633
+ }
539
634
  for (const [key, value] of Object.entries(params)) {
540
635
  if (value !== void 0) {
541
636
  queryParams[key] = value;
@@ -545,6 +640,10 @@ var CallForge = class _CallForge {
545
640
  const qs = sorted.map((k) => `${k}=${encodeURIComponent(queryParams[k])}`).join("&");
546
641
  return `${endpoint}/v1/tracking/session?${qs}`;
547
642
  }
643
+ buildBootstrapUrl() {
644
+ const { endpoint, categoryId } = this.config;
645
+ return `${endpoint}/v1/tracking/bootstrap?categoryId=${encodeURIComponent(categoryId)}`;
646
+ }
548
647
  buildLocationUrl(locationId) {
549
648
  const { endpoint } = this.config;
550
649
  if (!locationId) {
@@ -582,15 +681,16 @@ for(var i=0;i<ap.length;i++){var v=u.get(ap[i]);if(v)p[ap[i]]=v}
582
681
  var site='${config.siteKey || ""}'||location.hostname||'unknown-site';
583
682
  var key='cf_tracking_v1_'+site+'_${categoryId}';
584
683
  var lkey='cf_location_v1_'+site;
684
+ var bkey='cf_bootstrap_v1_'+site+'_${categoryId}';
585
685
  try{
586
686
  var cl=JSON.parse(localStorage.getItem(lkey));
587
687
  if(cl&&cl.expiresAt>Date.now()+30000){
588
- if(!loc||(loc&&cl.locId===loc)){window.__cfTrackingLocation=Promise.resolve(cl)}
688
+ if(!loc||(loc&&cl.locId===loc)){if(cl.location&&!Array.isArray(cl.location.zipOptions))cl.location.zipOptions=[];window.__cfTrackingLocation=Promise.resolve(cl)}
589
689
  }}catch(e){}
590
690
  if(!window.__cfTrackingLocation){
591
691
  var lurl='${endpoint}/v1/tracking/location';
592
692
  if(loc)lurl+='?loc_physical_ms='+encodeURIComponent(loc);
593
- window.__cfTrackingLocation=fetch(lurl,{credentials:'omit'}).then(function(r){if(!r.ok)throw new Error('tracking preload location failed');return r.json()}).then(function(d){d.locId=loc;try{localStorage.setItem(lkey,JSON.stringify({locId:loc,location:d.location?{city:d.location.city,state:d.location.state,stateCode:d.location.stateCode}:null,expiresAt:d.expiresAt,tokenVersion:'v1'}))}catch(e){}return d});
693
+ window.__cfTrackingLocation=fetch(lurl,{credentials:'omit'}).then(function(r){if(!r.ok)throw new Error('tracking preload location failed');return r.json()}).then(function(d){var z=Array.isArray(d.zipOptions)?d.zipOptions.filter(function(x){return /^\\d{5}$/.test(x)}):[];d.locId=loc;d.zipOptions=z;try{localStorage.setItem(lkey,JSON.stringify({locId:loc,location:d.location?{city:d.location.city,state:d.location.state,stateCode:d.location.stateCode,zipOptions:z}:null,expiresAt:d.expiresAt,tokenVersion:'v1'}))}catch(e){}return d});
594
694
  }
595
695
  var token=null;
596
696
  try{
@@ -601,12 +701,18 @@ token=(!loc||c.locId===loc)?c.sessionToken:null;
601
701
  var cp=c.params||{};
602
702
  p=Object.assign({},cp,p);
603
703
  }}catch(e){}
704
+ var bt=null;
705
+ try{
706
+ var cb=JSON.parse(localStorage.getItem(bkey));
707
+ if(cb&&typeof cb.token==='string'&&cb.expiresAt>Date.now()+10000)bt=cb.token;
708
+ }catch(e){}
709
+ var bp=bt?Promise.resolve({bootstrapToken:bt}):fetch('${endpoint}/v1/tracking/bootstrap?categoryId=${categoryId}',{credentials:'omit'}).then(function(r){if(!r.ok)return null;return r.json()}).then(function(b){if(!b||typeof b.bootstrapToken!=='string'||typeof b.expiresAt!=='number')return null;try{localStorage.setItem(bkey,JSON.stringify({token:b.bootstrapToken,expiresAt:b.expiresAt,tokenVersion:'b1'}))}catch(e){}return b}).catch(function(){return null});
604
710
  var url='${endpoint}/v1/tracking/session?categoryId=${categoryId}';
605
711
  if(loc)url+='&loc_physical_ms='+loc;
606
712
  if(token)url+='&sessionToken='+encodeURIComponent(token);
607
713
  var ks=Object.keys(p).sort();
608
714
  for(var j=0;j<ks.length;j++)url+='&'+ks[j]+'='+encodeURIComponent(p[ks[j]]);
609
- window.__cfTracking=fetch(url,{credentials:'omit'}).then(function(r){if(!r.ok)throw new Error('tracking preload failed');return r.json()}).then(function(d){d.params=p;d.locId=loc;try{localStorage.setItem(key,JSON.stringify({locId:loc,sessionToken:d.sessionToken,leaseId:d.leaseId,phoneNumber:d.phoneNumber,expiresAt:d.expiresAt,tokenVersion:'v1',params:p}))}catch(e){}return d});
715
+ window.__cfTracking=bp.then(function(b){if(b&&b.bootstrapToken)url+='&bootstrapToken='+encodeURIComponent(b.bootstrapToken);return fetch(url,{credentials:'omit'})}).then(function(r){if(!r.ok)throw new Error('tracking preload failed');return r.json()}).then(function(d){d.params=p;d.locId=loc;try{localStorage.setItem(key,JSON.stringify({locId:loc,sessionToken:d.sessionToken,leaseId:d.leaseId,phoneNumber:d.phoneNumber,expiresAt:d.expiresAt,tokenVersion:'v1',params:p}))}catch(e){}return d});
610
716
  })();`.replace(/\n/g, "");
611
717
  return `<link rel="preconnect" href="${endpoint}">
612
718
  <script>${script}</script>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@callforge/tracking-client",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -17,17 +17,17 @@
17
17
  "files": [
18
18
  "dist"
19
19
  ],
20
- "devDependencies": {
21
- "jsdom": "^27.4.0",
22
- "tsup": "^8.0.0",
23
- "typescript": "^5.3.0",
24
- "vitest": "^1.6.0",
25
- "@callforge/tsconfig": "0.0.0"
26
- },
27
20
  "scripts": {
28
21
  "build": "tsup src/index.ts --format esm,cjs --dts",
29
22
  "clean": "rm -rf dist",
30
23
  "test": "vitest run",
31
24
  "test:watch": "vitest"
25
+ },
26
+ "devDependencies": {
27
+ "@callforge/tsconfig": "workspace:*",
28
+ "jsdom": "^27.4.0",
29
+ "tsup": "^8.0.0",
30
+ "typescript": "^5.3.0",
31
+ "vitest": "^1.6.0"
32
32
  }
33
- }
33
+ }