@sailfish-ai/recorder 1.7.46 → 1.7.49

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.
@@ -15,6 +15,7 @@ export const ReportIssueContext = {
15
15
  apiKey: null,
16
16
  backendApi: null,
17
17
  triageBaseUrl: "https://app.sailfishqa.com",
18
+ deactivateIsolation: () => { },
18
19
  };
19
20
  let modalEl = null;
20
21
  let currentState = {
@@ -94,6 +95,8 @@ export function setupIssueReporting(options) {
94
95
  ReportIssueContext.apiKey = options.apiKey;
95
96
  ReportIssueContext.backendApi = options.backendApi;
96
97
  ReportIssueContext.resolveSessionId = options.getSessionId;
98
+ if (options.customBaseUrl)
99
+ ReportIssueContext.triageBaseUrl = options.customBaseUrl;
97
100
  ReportIssueContext.shortcuts = mergeShortcutsConfig(options.shortcuts);
98
101
  const { shortcuts } = ReportIssueContext;
99
102
  window.addEventListener("keydown", (e) => {
@@ -165,7 +168,7 @@ function getSessionIdSafely() {
165
168
  }
166
169
  return ReportIssueContext.resolveSessionId();
167
170
  }
168
- export function openReportIssueModal(customBaseUrl) {
171
+ export function openReportIssueModal() {
169
172
  if (isRecording) {
170
173
  stopRecording();
171
174
  return;
@@ -173,11 +176,9 @@ export function openReportIssueModal(customBaseUrl) {
173
176
  injectModalHTML();
174
177
  if (modalEl)
175
178
  document.body.appendChild(modalEl);
176
- // Store custom base URL globally for reuse
177
- ReportIssueContext.triageBaseUrl =
178
- customBaseUrl ?? "https://app.sailfishqa.com";
179
179
  }
180
180
  function closeModal() {
181
+ ReportIssueContext.deactivateIsolation();
181
182
  // ✅ Explicitly blur the focused element
182
183
  if (document.activeElement instanceof HTMLElement) {
183
184
  document.activeElement.blur();
@@ -198,6 +199,174 @@ function closeModal() {
198
199
  if (timerInterval)
199
200
  clearInterval(timerInterval);
200
201
  }
202
+ function activateModalIsolation(modal) {
203
+ // A. Ensure modal is a proper dialog & focus anchor
204
+ modal.setAttribute("role", "dialog");
205
+ modal.setAttribute("aria-modal", "true");
206
+ if (!modal.hasAttribute("tabindex"))
207
+ modal.setAttribute("tabindex", "-1");
208
+ // What element we want to keep focused
209
+ const focusEl = modal.querySelector("#sf-issue-description") ||
210
+ modal.querySelector("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])") ||
211
+ modal;
212
+ // Put sentinels to keep Tab inside
213
+ const startSentinel = document.createElement("div");
214
+ const endSentinel = document.createElement("div");
215
+ startSentinel.tabIndex = 0;
216
+ endSentinel.tabIndex = 0;
217
+ startSentinel.style.position = endSentinel.style.position = "fixed"; // not visible
218
+ startSentinel.style.width = endSentinel.style.width = "1px";
219
+ startSentinel.style.height = endSentinel.style.height = "1px";
220
+ startSentinel.style.outline = endSentinel.style.outline = "none";
221
+ modal.prepend(startSentinel);
222
+ modal.append(endSentinel);
223
+ const getFocusable = () => Array.from(modal.querySelectorAll("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])")).filter((el) => !el.hasAttribute("disabled") && el.offsetParent !== null);
224
+ const cycleFocus = (forward) => {
225
+ const list = getFocusable();
226
+ if (list.length === 0)
227
+ return;
228
+ const target = forward ? list[0] : list[list.length - 1];
229
+ target.focus({ preventScroll: true });
230
+ };
231
+ startSentinel.addEventListener("focus", () => cycleFocus(false));
232
+ endSentinel.addEventListener("focus", () => cycleFocus(true));
233
+ // B. Event quarantine: block interactions outside modal
234
+ const quarantine = (e) => {
235
+ const t = e.target;
236
+ if (!t)
237
+ return;
238
+ if (!modal.contains(t)) {
239
+ // Stop any event that could lead to focus shift or interaction
240
+ e.stopImmediatePropagation();
241
+ e.preventDefault();
242
+ // Snap focus back inside immediately
243
+ refocus();
244
+ }
245
+ };
246
+ // We capture broadly to beat most libs
247
+ const quarantineEvents = [
248
+ "mousedown",
249
+ "mouseup",
250
+ "click",
251
+ "pointerdown",
252
+ "pointerup",
253
+ "touchstart",
254
+ "touchend",
255
+ "wheel",
256
+ "keydown",
257
+ "keyup",
258
+ "focus",
259
+ "focusin",
260
+ "focusout",
261
+ "blur",
262
+ ];
263
+ quarantineEvents.forEach((type) => document.addEventListener(type, quarantine, true));
264
+ // C. Focus watchdog: if *anything* steals focus (even iframes), snap back.
265
+ let rafId = 0;
266
+ let lastSelection = null;
267
+ const textarea = focusEl instanceof HTMLTextAreaElement ? focusEl : null;
268
+ // Track caret to restore it when we yank focus back
269
+ const trackCaret = () => {
270
+ if (textarea && document.activeElement === textarea) {
271
+ try {
272
+ lastSelection = {
273
+ start: textarea.selectionStart ?? textarea.value.length,
274
+ end: textarea.selectionEnd ?? textarea.value.length,
275
+ };
276
+ }
277
+ catch {
278
+ // ignore
279
+ }
280
+ }
281
+ };
282
+ const refocus = () => {
283
+ const target = modal.querySelector(":focus") ||
284
+ textarea ||
285
+ focusEl ||
286
+ modal;
287
+ // Put focus back
288
+ target.focus({ preventScroll: true });
289
+ // Restore caret if possible
290
+ if (textarea && document.activeElement === textarea && lastSelection) {
291
+ try {
292
+ textarea.setSelectionRange(lastSelection.start, lastSelection.end, "none");
293
+ }
294
+ catch {
295
+ // ignore
296
+ }
297
+ }
298
+ };
299
+ // NEW: selection-aware helper (don’t refocus if selection lives inside modal)
300
+ const selectionInsideModal = () => {
301
+ const sel = typeof document.getSelection === "function"
302
+ ? document.getSelection()
303
+ : null;
304
+ if (!sel)
305
+ return false;
306
+ // check anchor/focus nodes first
307
+ const nodes = [sel.anchorNode, sel.focusNode].filter(Boolean);
308
+ if (nodes.some((n) => modal.contains(n)))
309
+ return true;
310
+ // fallback to range container if available
311
+ if (sel.rangeCount > 0) {
312
+ const container = sel.getRangeAt(0).commonAncestorContainer;
313
+ if (container &&
314
+ modal.contains(container.nodeType === 3 ? container.parentNode : container)) {
315
+ return true;
316
+ }
317
+ }
318
+ return false;
319
+ };
320
+ // NEW: tiny hysteresis to ignore transient focus blips
321
+ const ESCAPED_FRAMES_THRESHOLD = 2;
322
+ let escapedFrames = 0;
323
+ const watchdog = () => {
324
+ const ae = document.activeElement;
325
+ const escaped = ae === document.body || ae == null || !modal.contains(ae);
326
+ const selectingInside = selectionInsideModal();
327
+ if (escaped && !selectingInside) {
328
+ escapedFrames += 1;
329
+ if (escapedFrames >= ESCAPED_FRAMES_THRESHOLD) {
330
+ refocus();
331
+ escapedFrames = 0;
332
+ }
333
+ }
334
+ else {
335
+ escapedFrames = 0;
336
+ trackCaret();
337
+ }
338
+ rafId = window.requestAnimationFrame(watchdog);
339
+ };
340
+ rafId = window.requestAnimationFrame(watchdog);
341
+ // Also bounce on immediate blur/focusout — but skip if selection is inside modal
342
+ const onBlurLike = () => {
343
+ // Let the blur settle, then snap back if truly escaped
344
+ setTimeout(() => {
345
+ const ae = document.activeElement;
346
+ const escaped = ae === document.body || ae == null || !modal.contains(ae);
347
+ if (escaped && !selectionInsideModal()) {
348
+ refocus();
349
+ }
350
+ }, 0);
351
+ };
352
+ window.addEventListener("blur", onBlurLike, true);
353
+ document.addEventListener("focusout", onBlurLike, true);
354
+ // Initial focus
355
+ setTimeout(() => focusEl.focus({ preventScroll: true }), 0);
356
+ // Cleanup
357
+ return () => {
358
+ try {
359
+ startSentinel.remove();
360
+ endSentinel.remove();
361
+ }
362
+ catch { }
363
+ quarantineEvents.forEach((type) => document.removeEventListener(type, quarantine, true));
364
+ window.removeEventListener("blur", onBlurLike, true);
365
+ document.removeEventListener("focusout", onBlurLike, true);
366
+ if (rafId)
367
+ cancelAnimationFrame(rafId);
368
+ };
369
+ }
201
370
  function injectModalHTML(initialMode = "lookback") {
202
371
  if (modalEl) {
203
372
  modalEl.remove();
@@ -349,6 +518,7 @@ function injectModalHTML(initialMode = "lookback") {
349
518
  currentState.mode = initialMode;
350
519
  document.body.appendChild(modalEl);
351
520
  bindListeners();
521
+ ReportIssueContext.deactivateIsolation = activateModalIsolation(modalEl);
352
522
  }
353
523
  function setActiveTab(mode) {
354
524
  currentState.mode = mode;
package/dist/index.js CHANGED
@@ -1,11 +1,4 @@
1
1
  const DEBUG = import.meta.env.VITE_DEBUG ? import.meta.env.VITE_DEBUG : false;
2
- // ── SSR guards ─────────────────────────────────────────
3
- const HAS_WINDOW = typeof globalThis !== "undefined" &&
4
- typeof globalThis.window !== "undefined";
5
- const HAS_DOCUMENT = typeof globalThis !== "undefined" &&
6
- typeof globalThis.document !== "undefined";
7
- const HAS_LOCAL_STORAGE = typeof globalThis !== "undefined" && "localStorage" in globalThis;
8
- const HAS_SESSION_STORAGE = typeof globalThis !== "undefined" && "sessionStorage" in globalThis;
9
2
  // import { NetworkRecordOptions } from "@sailfish-rrweb/rrweb-plugin-network-record";
10
3
  import { v4 as uuidv4 } from "uuid";
11
4
  import { NetworkRequestEventId, STATIC_EXTENSIONS, xSf3RidHeader, } from "./constants";
@@ -14,6 +7,8 @@ import { fetchCaptureSettings, sendDomainsToNotPropagateHeaderTo, startRecording
14
7
  import { setupIssueReporting } from "./inAppReportIssueModal";
15
8
  import { sendMapUuidIfAvailable } from "./mapUuid";
16
9
  import { getUrlAndStoredUuids, initializeConsolePlugin, initializeDomContentEvents, initializeRecording, } from "./recording";
10
+ import { HAS_DOCUMENT, HAS_LOCAL_STORAGE, HAS_SESSION_STORAGE, HAS_WINDOW, } from "./runtimeEnv";
11
+ import { getOrSetSessionId } from "./session";
17
12
  import { sendEvent, sendMessage } from "./websocket";
18
13
  // Default list of domains to ignore
19
14
  const DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT = [
@@ -33,234 +28,12 @@ const BAD_HTTP_STATUS = [
33
28
  ];
34
29
  const CORS_KEYWORD = "CORS";
35
30
  export const STORAGE_VERSION = 1;
36
- export const DYNAMIC_PASSED_HOSTS_KEY = "dynamicPassedHosts";
37
- export const DYNAMIC_EXCLUDED_HOSTS_KEY = "dynamicExcludedHosts";
38
31
  const SF_API_KEY_FOR_UPDATE = "sailfishApiKey";
39
32
  const SF_BACKEND_API = "sailfishBackendApi";
40
33
  const INCLUDE = "include";
41
34
  const SAME_ORIGIN = "same-origin";
42
35
  const ALLOWED_HEADERS_HEADER = "access-control-allow-headers";
43
36
  const OPTIONS = "OPTIONS";
44
- export const dynamicExcludedHosts = new Set();
45
- export const dynamicPassedHosts = new Set();
46
- // 1️⃣ dynamicExcludedHosts.add override
47
- const originalExcludedAdd = dynamicExcludedHosts.add;
48
- dynamicExcludedHosts.add = (host) => {
49
- const cleaned = host?.trim();
50
- if (!cleaned || dynamicExcludedHosts.has(cleaned)) {
51
- return dynamicExcludedHosts;
52
- }
53
- // 1. Add to the excluded Set
54
- originalExcludedAdd.call(dynamicExcludedHosts, cleaned);
55
- // 2. If it was previously in passed, remove it
56
- if (dynamicPassedHosts.has(cleaned)) {
57
- dynamicPassedHosts.delete(cleaned);
58
- }
59
- // 3. Consolidate dynamic exclusions into smart wildcards
60
- const consolidated = consolidateDynamicExclusions(dynamicExcludedHosts);
61
- dynamicExcludedHosts.clear();
62
- for (const p of consolidated) {
63
- originalExcludedAdd.call(dynamicExcludedHosts, p);
64
- }
65
- // 4. Persist wrapper to localStorage
66
- try {
67
- if (HAS_LOCAL_STORAGE) {
68
- // TODO decouple into two logical pieces
69
- const wrapper = {
70
- version: STORAGE_VERSION,
71
- entries: {},
72
- };
73
- const now = Date.now();
74
- for (const p of dynamicExcludedHosts) {
75
- wrapper.entries[p] = now;
76
- }
77
- localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify(wrapper));
78
- }
79
- }
80
- catch (e) {
81
- if (DEBUG)
82
- console.warn("Persist dynamicExcludedHosts failed:", e);
83
- }
84
- // 5. Notify backend of the updated Set
85
- updateExcludedHostsStorageAndBackend(dynamicExcludedHosts);
86
- return dynamicExcludedHosts;
87
- };
88
- // 2️⃣ dynamicPassedHosts.add override (timestamps refreshed on every call)
89
- const originalPassedAdd = dynamicPassedHosts.add;
90
- dynamicPassedHosts.add = (host) => {
91
- const cleaned = host?.trim();
92
- if (!cleaned) {
93
- return dynamicPassedHosts;
94
- }
95
- // 1. Persist wrapper to localStorage (update timestamp unconditionally)
96
- try {
97
- if (HAS_LOCAL_STORAGE) {
98
- // TODO decouple into two logical pieces
99
- const raw = localStorage.getItem(DYNAMIC_PASSED_HOSTS_KEY);
100
- let wrapper;
101
- if (!raw) {
102
- wrapper = { version: STORAGE_VERSION, entries: {} };
103
- }
104
- else {
105
- const parsed = JSON.parse(raw);
106
- if (Array.isArray(parsed)) {
107
- // migrate old array → wrapper
108
- wrapper = {
109
- version: STORAGE_VERSION,
110
- entries: Object.fromEntries(parsed.map((h) => [h, Date.now()])),
111
- };
112
- }
113
- else if (parsed &&
114
- typeof parsed === "object" &&
115
- "entries" in parsed &&
116
- typeof parsed.entries === "object") {
117
- wrapper = parsed;
118
- }
119
- else {
120
- wrapper = { version: STORAGE_VERSION, entries: {} };
121
- }
122
- }
123
- // always set/update the timestamp
124
- wrapper.entries[cleaned] = Date.now();
125
- wrapper.version = STORAGE_VERSION;
126
- localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify(wrapper));
127
- }
128
- }
129
- catch (e) {
130
- if (DEBUG)
131
- console.warn("Persist dynamicPassedHosts failed:", e);
132
- }
133
- // 2. Add to the passed Set if not already present
134
- if (!dynamicPassedHosts.has(cleaned)) {
135
- originalPassedAdd.call(dynamicPassedHosts, cleaned);
136
- }
137
- return dynamicPassedHosts;
138
- };
139
- /**
140
- * Notify the backend of the updated dynamicExcludedHosts
141
- */
142
- function updateExcludedHostsStorageAndBackend(dynamicExcludedHosts) {
143
- if (!HAS_SESSION_STORAGE)
144
- return;
145
- const apiKeyForUpdate = sessionStorage.getItem(SF_API_KEY_FOR_UPDATE) || "";
146
- const apiForUpdate = sessionStorage.getItem(SF_BACKEND_API) || "";
147
- if (!apiForUpdate) {
148
- return;
149
- }
150
- sendDomainsToNotPropagateHeaderTo(apiKeyForUpdate, [...dynamicExcludedHosts, ...DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT], apiForUpdate).catch((error) => console.error("Failed to send domains to not propagate header to:", error));
151
- }
152
- /**
153
- * Given a set of excluded hostPaths (like "foo.com/bar", "foo.com/baz", etc.),
154
- * find any path prefixes under each host that appear ≥ threshold times,
155
- * and replace their individual entries with a single wildcard pattern
156
- * (e.g. "foo.com/bar*").
157
- */
158
- export function consolidateDynamicExclusions(hostPathSet, threshold = 3) {
159
- // 1️⃣ Group by host
160
- const byHost = {};
161
- for (const hostPath of hostPathSet) {
162
- const [host, ...rest] = hostPath.split("/");
163
- const path = rest.length ? `/${rest.join("/")}` : "/";
164
- (byHost[host] ??= []).push(path);
165
- }
166
- const newSet = new Set();
167
- for (const host in byHost) {
168
- const paths = byHost[host];
169
- if (paths.length < threshold) {
170
- // not enough entries to bother; keep them as-is
171
- for (const p of paths)
172
- newSet.add(`${host}${p}`);
173
- continue;
174
- }
175
- const root = { count: 0, children: new Map() };
176
- for (const p of paths) {
177
- root.count++;
178
- const segments = p.split("/").filter(Boolean);
179
- let node = root;
180
- for (const seg of segments) {
181
- if (!node.children.has(seg)) {
182
- node.children.set(seg, { count: 0, children: new Map() });
183
- }
184
- node = node.children.get(seg);
185
- node.count++;
186
- }
187
- }
188
- // 3️⃣ Walk the trie and pick prefixes where node.count ≥ threshold
189
- function gather(node, prefixSegments) {
190
- // if this node covers ≥ threshold of this host’s paths, collapse here
191
- if (node.count >= threshold && prefixSegments.length > 0) {
192
- const wildcardPath = "/" + prefixSegments.join("/") + "/*";
193
- newSet.add(`${host}${wildcardPath}`);
194
- return;
195
- }
196
- // otherwise, recurse into children
197
- for (const [seg, child] of node.children) {
198
- gather(child, prefixSegments.concat(seg));
199
- }
200
- }
201
- gather(root, []);
202
- }
203
- return newSet;
204
- }
205
- // 2️⃣ Load & evict old entries (>7 days) + version check for Excluded
206
- if (HAS_LOCAL_STORAGE) {
207
- (() => {
208
- const stored = localStorage.getItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
209
- if (!stored)
210
- return;
211
- try {
212
- const wrapper = JSON.parse(stored);
213
- // if it's from an old version, drop it
214
- if (wrapper.version !== STORAGE_VERSION) {
215
- localStorage.removeItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
216
- return;
217
- }
218
- const now = Date.now();
219
- const valid = {};
220
- for (const [host, ts] of Object.entries(wrapper.entries)) {
221
- if (now - ts < 7 * 24 * 60 * 60 * 1000) {
222
- dynamicExcludedHosts.add(host);
223
- valid[host] = ts;
224
- }
225
- }
226
- localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify({ version: STORAGE_VERSION, entries: valid }));
227
- }
228
- catch (e) {
229
- if (DEBUG)
230
- console.warn("Failed to parse dynamicExcludedHosts:", e);
231
- localStorage.removeItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
232
- }
233
- })();
234
- }
235
- // 3️⃣ Load & evict old entries (>7 days) + version check for Passed
236
- if (HAS_LOCAL_STORAGE) {
237
- (() => {
238
- const stored = localStorage.getItem(DYNAMIC_PASSED_HOSTS_KEY);
239
- if (!stored)
240
- return;
241
- try {
242
- const wrapper = JSON.parse(stored);
243
- if (wrapper.version !== STORAGE_VERSION) {
244
- localStorage.removeItem(DYNAMIC_PASSED_HOSTS_KEY);
245
- return;
246
- }
247
- const now = Date.now();
248
- const valid = {};
249
- for (const [host, ts] of Object.entries(wrapper.entries)) {
250
- if (now - ts < 7 * 24 * 60 * 60 * 1000) {
251
- dynamicPassedHosts.add(host);
252
- valid[host] = ts;
253
- }
254
- }
255
- localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify({ version: STORAGE_VERSION, entries: valid }));
256
- }
257
- catch (e) {
258
- if (DEBUG)
259
- console.warn("Failed to parse dynamicPassedHosts:", e);
260
- localStorage.removeItem(DYNAMIC_PASSED_HOSTS_KEY);
261
- }
262
- })();
263
- }
264
37
  const ActionType = {
265
38
  PROPAGATE: "propagate",
266
39
  IGNORE: "ignore",
@@ -323,7 +96,6 @@ function trackDomainChanges() {
323
96
  timestamp,
324
97
  page_visit_uuid: pageVisitUUID,
325
98
  prev_page_visit_uuid: prevPageVisitUUID,
326
- session_id: getOrSetSessionId(),
327
99
  },
328
100
  });
329
101
  }
@@ -384,16 +156,6 @@ function getOrSetUserDeviceUuid() {
384
156
  }
385
157
  return userDeviceUuid;
386
158
  }
387
- // Storing the sailfishSessionId in window.name, as window.name retains its value after a page refresh
388
- // but resets when a new tab (including a duplicated tab) is opened.
389
- function getOrSetSessionId() {
390
- if (!HAS_WINDOW)
391
- return uuidv4();
392
- if (!window.name) {
393
- window.name = uuidv4();
394
- }
395
- return window.name;
396
- }
397
159
  // Function to handle resetting the sessionId when the page becomes visible again
398
160
  function handleVisibilityChange() {
399
161
  if (document.visibilityState === "visible") {
@@ -655,7 +417,6 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
655
417
  else {
656
418
  return target.apply(thisArg, args);
657
419
  }
658
- const domain = new URL(url, window.location.href).hostname;
659
420
  // 2️⃣ Skip header injection if excluded
660
421
  if (shouldSkipHeadersPropagation(url, domainsToNotPropagateHeadersTo)) {
661
422
  return target.apply(thisArg, args);
@@ -761,7 +522,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
761
522
  return await target.call(thisArg, input, modifiedInit);
762
523
  }
763
524
  }
764
- // Helper to retry a fetch without the X-Sf3-Rid header if the initial attempt fails due to that header
525
+ // Helper to retry a fetch without the X-Sf3-Rid header when we receive 400/403 errors
765
526
  async function retryWithoutPropagateHeaders(target, thisArg, args, url) {
766
527
  try {
767
528
  // **Fix:** Properly await and clone the request without the tracing header
@@ -856,6 +617,10 @@ export async function startRecording({ apiKey, backendApi = "https://api-service
856
617
  trackDomainChanges();
857
618
  sessionStorage.setItem(SF_API_KEY_FOR_UPDATE, apiKey);
858
619
  sessionStorage.setItem(SF_BACKEND_API, backendApi);
620
+ sendDomainsToNotPropagateHeaderTo(apiKey, [
621
+ ...domainsToNotPropagateHeaderTo,
622
+ ...DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT,
623
+ ], backendApi).catch((error) => console.error("Failed to send domains to not propagate header to:", error));
859
624
  // Setup interceptors with custom ignore and propagate domains
860
625
  setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo);
861
626
  setupFetchInterceptor(domainsToNotPropagateHeaderTo);
@@ -893,6 +658,7 @@ export const initRecorder = async (options) => {
893
658
  backendApi: options.backendApi ?? "https://api-service.sailfishqa.com",
894
659
  getSessionId: () => getOrSetSessionId(),
895
660
  shortcuts: options.reportIssueShortcuts,
661
+ customBaseUrl: options.customBaseUrl,
896
662
  });
897
663
  });
898
664
  };
@@ -0,0 +1,7 @@
1
+ // ── SSR guards ─────────────────────────────────────────
2
+ export const HAS_WINDOW = typeof globalThis !== "undefined" &&
3
+ typeof globalThis.window !== "undefined";
4
+ export const HAS_DOCUMENT = typeof globalThis !== "undefined" &&
5
+ typeof globalThis.document !== "undefined";
6
+ export const HAS_LOCAL_STORAGE = typeof globalThis !== "undefined" && "localStorage" in globalThis;
7
+ export const HAS_SESSION_STORAGE = typeof globalThis !== "undefined" && "sessionStorage" in globalThis;