@sailfish-ai/recorder 1.7.20 → 1.7.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16,19 +16,24 @@ const DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT = [
16
16
  "*.googleapis.com",
17
17
  "*.amazonaws.com", // Exclude AWS S3
18
18
  "*.smooch.io", // Exclude smooch-related requests
19
- "*.zendesk.com", // Exclude zendesk-related requests
19
+ // Exclude zendesk-related requests
20
+ "*.zendesk.com",
21
+ "*.zdassets.com",
20
22
  ];
21
23
  const BAD_HTTP_STATUS = [
22
24
  400, // BAD REQUEST
23
25
  403, // FORBIDDEN
24
26
  ];
25
27
  const CORS_KEYWORD = "CORS";
28
+ const STORAGE_VERSION = 1;
26
29
  const DYNAMIC_PASSED_HOSTS_KEY = "dynamicPassedHosts";
27
30
  const DYNAMIC_EXCLUDED_HOSTS_KEY = "dynamicExcludedHosts";
28
31
  const SF_API_KEY_FOR_UPDATE = "sailfishApiKey";
29
32
  const SF_BACKEND_API = "sailfishBackendApi";
30
33
  const INCLUDE = "include";
31
34
  const SAME_ORIGIN = "same-origin";
35
+ const ALLOWED_HEADERS_HEADER = "access-control-allow-headers";
36
+ const OPTIONS = "OPTIONS";
32
37
  /**
33
38
  * Notify the backend of the updated dynamicExcludedHosts
34
39
  */
@@ -42,54 +47,106 @@ function updateExcludedHostsStorageAndBackend(dynamicExcludedHosts) {
42
47
  }
43
48
  const dynamicExcludedHosts = new Set();
44
49
  const dynamicPassedHosts = new Set();
45
- // Load initial dynamicExcludedHosts from localStorage
50
+ // 2️⃣ Load & evict old entries (>7 days) + version check for Excluded
46
51
  (() => {
47
52
  const stored = localStorage.getItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
48
- if (stored) {
49
- try {
50
- JSON.parse(stored).forEach((host) => dynamicExcludedHosts.add(host));
51
- }
52
- catch (e) {
53
- if (DEBUG)
54
- console.log("Failed to parse dynamicExcludedHosts from storage", e);
53
+ if (!stored)
54
+ return;
55
+ try {
56
+ const wrapper = JSON.parse(stored);
57
+ // if it's from an old version, drop it
58
+ if (wrapper.version !== STORAGE_VERSION) {
55
59
  localStorage.removeItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
60
+ return;
61
+ }
62
+ const now = Date.now();
63
+ const valid = {};
64
+ for (const [host, ts] of Object.entries(wrapper.entries)) {
65
+ if (now - ts < 7 * 24 * 60 * 60 * 1000) {
66
+ dynamicExcludedHosts.add(host);
67
+ valid[host] = ts;
68
+ }
56
69
  }
70
+ localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify({ version: STORAGE_VERSION, entries: valid }));
71
+ }
72
+ catch (e) {
73
+ if (DEBUG)
74
+ console.warn("Failed to parse dynamicExcludedHosts:", e);
75
+ localStorage.removeItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
57
76
  }
58
77
  })();
59
- // Load initial dynamicPassedHosts from localStorage
78
+ // 3️⃣ Load & evict old entries (>7 days) + version check for Passed
60
79
  (() => {
61
80
  const stored = localStorage.getItem(DYNAMIC_PASSED_HOSTS_KEY);
62
- if (stored) {
63
- try {
64
- JSON.parse(stored).forEach((host) => dynamicPassedHosts.add(host));
65
- }
66
- catch (e) {
67
- if (DEBUG)
68
- console.log("Failed to parse dynamicPassedHosts from storage", e);
81
+ if (!stored)
82
+ return;
83
+ try {
84
+ const wrapper = JSON.parse(stored);
85
+ if (wrapper.version !== STORAGE_VERSION) {
69
86
  localStorage.removeItem(DYNAMIC_PASSED_HOSTS_KEY);
87
+ return;
88
+ }
89
+ const now = Date.now();
90
+ const valid = {};
91
+ for (const [host, ts] of Object.entries(wrapper.entries)) {
92
+ if (now - ts < 7 * 24 * 60 * 60 * 1000) {
93
+ dynamicPassedHosts.add(host);
94
+ valid[host] = ts;
95
+ }
70
96
  }
97
+ localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify({ version: STORAGE_VERSION, entries: valid }));
98
+ }
99
+ catch (e) {
100
+ if (DEBUG)
101
+ console.warn("Failed to parse dynamicPassedHosts:", e);
102
+ localStorage.removeItem(DYNAMIC_PASSED_HOSTS_KEY);
71
103
  }
72
104
  })();
73
105
  // Override add() to persist updates to localStorage
74
106
  const originalExcludedAdd = dynamicExcludedHosts.add;
75
107
  dynamicExcludedHosts.add = (host) => {
76
108
  const cleanedHost = host?.trim();
77
- if (!cleanedHost) {
109
+ if (!cleanedHost || dynamicExcludedHosts.has(cleanedHost)) {
78
110
  return dynamicExcludedHosts;
79
111
  }
80
112
  originalExcludedAdd.call(dynamicExcludedHosts, cleanedHost);
81
- localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify(Array.from(dynamicExcludedHosts)));
113
+ try {
114
+ // read existing map or start fresh
115
+ const stored = localStorage.getItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
116
+ const obj = stored ? JSON.parse(stored) : {};
117
+ obj[cleanedHost] = Date.now();
118
+ localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify(obj));
119
+ }
120
+ catch (e) {
121
+ if (DEBUG)
122
+ console.warn("Persist dynamicExcludedHosts failed:", e);
123
+ }
82
124
  updateExcludedHostsStorageAndBackend(dynamicExcludedHosts);
83
125
  return dynamicExcludedHosts;
84
126
  };
85
127
  const originalPassedAdd = dynamicPassedHosts.add;
128
+ // === UPDATED: override to store timestamp on add (passed hosts) ===
86
129
  dynamicPassedHosts.add = (host) => {
87
130
  const cleanedHost = host?.trim();
88
131
  if (!cleanedHost) {
89
132
  return dynamicPassedHosts;
90
133
  }
134
+ // If we already have it, just return
135
+ if (dynamicPassedHosts.has(cleanedHost)) {
136
+ return dynamicPassedHosts;
137
+ }
91
138
  originalPassedAdd.call(dynamicPassedHosts, cleanedHost);
92
- localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify(Array.from(dynamicPassedHosts)));
139
+ // Persist a host->timestamp map
140
+ try {
141
+ const stored = localStorage.getItem(DYNAMIC_PASSED_HOSTS_KEY);
142
+ const obj = stored ? JSON.parse(stored) : {};
143
+ obj[cleanedHost] = Date.now();
144
+ localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify(obj));
145
+ }
146
+ catch (e) {
147
+ if (DEBUG)
148
+ console.warn("Persist dynamicPassedHosts failed:", e);
149
+ }
93
150
  return dynamicPassedHosts;
94
151
  };
95
152
  const ActionType = {
@@ -197,7 +254,10 @@ function getOrSetUserDeviceUuid() {
197
254
  let userDeviceUuid = localStorage.getItem("sailfishUserDeviceUuid");
198
255
  if (!userDeviceUuid) {
199
256
  userDeviceUuid = uuidv4();
200
- localStorage.setItem("sailfishUserDeviceUuid", userDeviceUuid);
257
+ try {
258
+ localStorage.setItem("sailfishUserDeviceUuid", userDeviceUuid);
259
+ }
260
+ catch { }
201
261
  }
202
262
  return userDeviceUuid;
203
263
  }
@@ -315,16 +375,12 @@ function shouldSkipHeadersPropagation(url, domainsToNotPropagateHeadersTo = [])
315
375
  function performOptionsPreflightForXHR(url, init, xSf3RidHeaderValue, domain) {
316
376
  return new Promise((resolve) => {
317
377
  const xhr = new XMLHttpRequest();
318
- xhr.open("OPTIONS", url, true);
319
- // Mirror credentials
378
+ xhr.open(OPTIONS, url, true);
320
379
  xhr.withCredentials = init.credentials === INCLUDE;
321
- // CORS preflight headers
322
- const method = (init.method || "GET").toUpperCase();
323
- xhr.setRequestHeader("Access-Control-Request-Method", method);
324
- const rawHeaders = init.headers instanceof Headers
325
- ? Object.entries(init.headers)
326
- : init.headers || {};
327
- const customHeaders = Object.keys(rawHeaders)
380
+ // Normalize headers into a standard Headers instance
381
+ const headerEntries = new Headers(init.headers || {});
382
+ const rawKeys = Array.from(headerEntries.keys());
383
+ const customHeaders = rawKeys
328
384
  .map((h) => h.toLowerCase())
329
385
  .filter((h) => ![
330
386
  "accept",
@@ -335,11 +391,25 @@ function performOptionsPreflightForXHR(url, init, xSf3RidHeaderValue, domain) {
335
391
  if (customHeaders.length) {
336
392
  xhr.setRequestHeader("Access-Control-Request-Headers", customHeaders.join(","));
337
393
  }
338
- // Add tracing header
339
- this.setRequestHeader(xSf3RidHeader, xSf3RidHeaderValue);
394
+ // Set the CORS preflight method header
395
+ xhr.setRequestHeader("Access-Control-Request-Method", (init.method || "GET").toUpperCase());
396
+ // Correctly add the tracing header to the XHR instance
397
+ xhr.setRequestHeader(xSf3RidHeader, xSf3RidHeaderValue);
340
398
  xhr.onload = () => {
341
399
  if (xhr.status >= 200 && xhr.status < 300) {
342
- resolve(ActionType.PROPAGATE);
400
+ const allowed = xhr.getResponseHeader(ALLOWED_HEADERS_HEADER);
401
+ if (allowed &&
402
+ allowed
403
+ .split(",")
404
+ .map((h) => h.trim().toLowerCase())
405
+ .includes(xSf3RidHeader.toLowerCase())) {
406
+ resolve(ActionType.PROPAGATE);
407
+ }
408
+ else {
409
+ DEBUG &&
410
+ console.log(`[XHR Interceptor] Header ${xSf3RidHeader} not allowed by preflight for ${domain}`);
411
+ resolve(ActionType.IGNORE);
412
+ }
343
413
  }
344
414
  else {
345
415
  DEBUG &&
@@ -401,19 +471,26 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
401
471
  console.warn(`Could not set X-Sf3-Rid header for ${url}`, e);
402
472
  }
403
473
  }
404
- // On CORS or network error during send, add domain to passed-hosts list
405
- this.addEventListener("error", () => {
474
+ // On CORS or network error during send, log it and add to excluded-hosts
475
+ this.addEventListener("error", (evt) => {
476
+ console.error(`[XHR Interceptor] Network error for ${domain} (${url}):`, evt, `status=${this.status}`, `statusText=${this.statusText}`);
406
477
  dynamicExcludedHosts.add(domain);
407
478
  }, { once: true });
408
- // On successful send (HTTP 2xx), add domain to passed-hosts list
479
+ // On load, log non-2xx statuses (including CORS-blocked status=0) and track passes
409
480
  this.addEventListener("load", () => {
410
481
  if (this.status === 0) {
411
- // status 0 on load is often a CORS‐blocked response
482
+ DEBUG &&
483
+ console.error(`[XHR Interceptor] CORS blocked (status=0) for ${domain} (${url})`, `statusText=${this.statusText}`);
412
484
  dynamicExcludedHosts.add(domain);
413
485
  }
414
- if (this.status >= 200 && this.status < 300) {
486
+ else if (this.status >= 200 && this.status < 300) {
415
487
  dynamicPassedHosts.add(domain);
416
488
  }
489
+ else {
490
+ DEBUG &&
491
+ console.error(`[XHR Interceptor] HTTP error ${this.status} ${this.statusText} for ${domain} (${url})`);
492
+ dynamicExcludedHosts.add(domain);
493
+ }
417
494
  }, { once: true });
418
495
  return originalSend.apply(this, args);
419
496
  };
@@ -447,37 +524,44 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = []) {
447
524
  }
448
525
  /**
449
526
  * Performs an OPTIONS preflight check to decide header propagation.
450
- * Returns 'propagate' if OPTIONS succeeds, 'ignore' otherwise.
527
+ * Returns PROPAGATE if both header & method are allowed, IGNORE if disallowed
528
+ * or on a CORS error, null if OPTIONS returned non-2xx.
451
529
  */
452
530
  async function performOptionsPreflight(target, thisArg, url, init, sessionId, domain) {
453
531
  try {
454
532
  const headers = new Headers(init.headers || {});
455
533
  headers.set(xSf3RidHeader, sessionId);
456
- const response = await target.call(thisArg, url, {
457
- method: "OPTIONS",
534
+ const opts = {
535
+ method: OPTIONS,
458
536
  headers,
459
- });
460
- if (response.ok) {
461
- return ActionType.PROPAGATE;
462
- }
463
- else {
537
+ mode: "cors",
538
+ credentials: init.credentials || SAME_ORIGIN,
539
+ };
540
+ const response = await target.call(thisArg, url, opts);
541
+ if (!response.ok) {
464
542
  DEBUG &&
465
543
  console.log(`[Fetch Interceptor] OPTIONS returned status ${response.status} for ${domain}`);
466
544
  return null;
467
545
  }
546
+ // 1️⃣ Check that our header is allowed
547
+ const allowedHeaders = response.headers
548
+ .get(ALLOWED_HEADERS_HEADER)
549
+ ?.split(",")
550
+ .map((h) => h.trim().toLowerCase()) || [];
551
+ if (!allowedHeaders.includes(xSf3RidHeader.toLowerCase())) {
552
+ DEBUG &&
553
+ console.log(`[Fetch Interceptor] Header ${xSf3RidHeader} not allowed by preflight for ${domain}`);
554
+ return ActionType.IGNORE;
555
+ }
556
+ return ActionType.PROPAGATE;
468
557
  }
469
558
  catch (error) {
470
- // Treat fetch errors (likely CORS failures) as ignore
471
- if (error instanceof TypeError || error?.message?.includes(CORS_KEYWORD)) {
559
+ if (error instanceof TypeError &&
560
+ error.message.toLowerCase().includes(CORS_KEYWORD.toLowerCase())) {
472
561
  DEBUG &&
473
562
  console.log(`[Fetch Interceptor] Preflight OPTIONS CORS error for ${domain}:`, error);
474
563
  return ActionType.IGNORE;
475
564
  }
476
- // Other failures also ignored as some APIs or reverse proxies (e.g. NGINX) don’t route
477
- // or handle OPTIONS requests, leading to:
478
- // * 404 Not Found
479
- // * 405 Method Not Allowed
480
- // * 500 Internal Server Error
481
565
  DEBUG &&
482
566
  console.log(`[Fetch Interceptor] Preflight OPTIONS failed for ${domain}:`, error);
483
567
  return null;
@@ -487,7 +571,7 @@ async function performOptionsPreflight(target, thisArg, url, init, sessionId, do
487
571
  function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
488
572
  const originalFetch = window.fetch;
489
573
  const sessionId = getOrSetSessionId();
490
- const cache = new Map();
574
+ const cachedDomains = new Map();
491
575
  window.fetch = new Proxy(originalFetch, {
492
576
  apply: async (target, thisArg, args) => {
493
577
  let input = args[0];
@@ -508,8 +592,8 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
508
592
  // Determine the target domain
509
593
  const domain = new URL(url, window.location.href).hostname;
510
594
  // Use cached decision if available
511
- if (cache.has(domain)) {
512
- const decision = cache.get(domain);
595
+ if (cachedDomains.has(domain)) {
596
+ const decision = cachedDomains.get(domain);
513
597
  if (decision === ActionType.IGNORE) {
514
598
  return target.apply(thisArg, args);
515
599
  }
@@ -519,7 +603,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
519
603
  }
520
604
  // Check exclusion domains and cache 'ignore'
521
605
  if (shouldSkipHeadersPropagation(url, domainsToNotPropagateHeadersTo)) {
522
- cache.set(domain, ActionType.IGNORE);
606
+ cachedDomains.set(domain, ActionType.IGNORE);
523
607
  return target.apply(thisArg, args);
524
608
  }
525
609
  let decision = ActionType.PROPAGATE;
@@ -533,7 +617,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
533
617
  dynamicExcludedHosts.add(domain);
534
618
  }
535
619
  }
536
- cache.set(domain, decision);
620
+ cachedDomains.set(domain, decision);
537
621
  if (decision === ActionType.PROPAGATE) {
538
622
  return injectHeaderWrapper(target, thisArg, args, input, init, sessionId, url);
539
623
  }
@@ -595,8 +679,8 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
595
679
  const errorMessage = error.message || "Fetch request failed";
596
680
  // Treat fetch errors (likely CORS failures) as ignore
597
681
  // Since some APIs or reverse proxies (such as NGINX) do not route or handle OPTIONS requests, CORS may occur while the request is being made.
598
- if (error instanceof TypeError ||
599
- error?.message?.includes(CORS_KEYWORD)) {
682
+ if (error instanceof TypeError &&
683
+ error?.message?.toLowerCase().includes(CORS_KEYWORD.toLowerCase())) {
600
684
  DEBUG &&
601
685
  console.log(`[Fetch Interceptor] CORS error for ${domain}:`, error);
602
686
  dynamicExcludedHosts.add(domain);
@@ -654,23 +738,44 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
654
738
  async function retryWithoutPropagateHeaders(target, thisArg, args, url) {
655
739
  const domain = new URL(url).hostname;
656
740
  try {
657
- // Retry the fetch without the header
658
- // const retryResponse = await originalFetch(retryRequest);
659
- const response = target.apply(thisArg, args);
660
- // Check if retry succeeded (no network error thrown, and not a 400/403 response)
661
- if (response.ok || !BAD_HTTP_STATUS.includes(response.status)) {
662
- // Mark this domain to exclude the header going forward without header
663
- dynamicExcludedHosts.add(domain);
664
- cache.set(domain, ActionType.IGNORE); // mark domain as 'ignore' in the cache
665
- // Log the original failure and the successful retry
666
- console.info(`Retried request to ${url} without ${xSf3RidHeader} succeeded. ` +
667
- `Added "${domain}" to header exclusion lists.`);
741
+ // **Fix:** Properly await and clone the request without the tracing header
742
+ let input = args[0];
743
+ let init = args[1] || {};
744
+ if (typeof input === "string" || input instanceof URL) {
745
+ const retryInit = { ...init };
746
+ const retryHeaders = new Headers(init.headers || {});
747
+ retryHeaders.delete(xSf3RidHeader);
748
+ retryInit.headers = retryHeaders;
749
+ const response = await target.call(thisArg, input, retryInit);
750
+ if (response.ok || !BAD_HTTP_STATUS.includes(response.status)) {
751
+ dynamicExcludedHosts.add(domain);
752
+ cachedDomains.set(domain, ActionType.IGNORE);
753
+ DEBUG &&
754
+ console.info(`Retried request to ${url} without ${xSf3RidHeader} succeeded. Added "${domain}" to exclusion list.`);
755
+ }
756
+ return response;
757
+ }
758
+ else if (input instanceof Request) {
759
+ const original = input;
760
+ const cloned = original.clone();
761
+ const retryHeaders = new Headers(cloned.headers);
762
+ retryHeaders.delete(xSf3RidHeader);
763
+ const retryReq = new Request(cloned, { headers: retryHeaders });
764
+ const response = await target.call(thisArg, retryReq, init);
765
+ if (response.ok || !BAD_HTTP_STATUS.includes(response.status)) {
766
+ dynamicExcludedHosts.add(domain);
767
+ cachedDomains.set(domain, ActionType.IGNORE);
768
+ DEBUG &&
769
+ console.info(`Retried request to ${url} without ${xSf3RidHeader} succeeded. Added "${domain}" to exclusion list.`);
770
+ }
771
+ return response;
772
+ }
773
+ else {
774
+ // Fallback
775
+ return target.apply(thisArg, args);
668
776
  }
669
- // Return the response from the retry attempt (successful or not)
670
- return response;
671
777
  }
672
778
  catch (retryError) {
673
- // Propagate the failure (no domain added to exclude lists since retry failed)
674
779
  DEBUG &&
675
780
  console.log(`Retry without ${xSf3RidHeader} for ${url} also failed:`, retryError);
676
781
  throw retryError;
@@ -689,13 +794,19 @@ export async function startRecording({ apiKey, backendApi = "https://api-service
689
794
  host?.trim() && originalExcludedAdd.call(dynamicExcludedHosts, host);
690
795
  });
691
796
  // Persist updated excluded hosts to localStorage
692
- localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify(Array.from(dynamicExcludedHosts)));
797
+ try {
798
+ localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify(Array.from(dynamicExcludedHosts)));
799
+ }
800
+ catch { }
693
801
  // Add provided domainsToPropagateHeaderTo to dynamicPassedHosts
694
802
  domainsToPropagateHeaderTo.forEach((host) => {
695
803
  originalPassedAdd.call(dynamicPassedHosts, host);
696
804
  });
697
805
  // Persist updated included hosts to localStorage
698
- localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify(Array.from(dynamicPassedHosts)));
806
+ try {
807
+ localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify(Array.from(dynamicPassedHosts)));
808
+ }
809
+ catch { }
699
810
  // Non-blocking GraphQL request to send the domains if provided
700
811
  if (dynamicExcludedHosts.size > 0) {
701
812
  sendDomainsToNotPropagateHeaderTo(apiKey, [...dynamicExcludedHosts, ...DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT], backendApi).catch((error) => console.error("Failed to send domains to not propagate header to:", error));
@@ -703,8 +814,8 @@ export async function startRecording({ apiKey, backendApi = "https://api-service
703
814
  sessionStorage.setItem(SF_API_KEY_FOR_UPDATE, apiKey);
704
815
  sessionStorage.setItem(SF_BACKEND_API, backendApi);
705
816
  // Setup interceptors with custom ignore and propagate domains
706
- setupXMLHttpRequestInterceptor(domainsToPropagateHeaderTo);
707
- setupFetchInterceptor(domainsToPropagateHeaderTo);
817
+ setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo);
818
+ setupFetchInterceptor(domainsToNotPropagateHeaderTo);
708
819
  gatherAndCacheDeviceInfo();
709
820
  try {
710
821
  const captureSettingsResponse = await fetchCaptureSettings(apiKey, backendApi);