@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.
- package/dist/inAppReportIssueModal.js +174 -4
- package/dist/index.js +8 -242
- package/dist/runtimeEnv.js +7 -0
- package/dist/sailfish-recorder.cjs.js +1 -1
- package/dist/sailfish-recorder.cjs.js.br +0 -0
- package/dist/sailfish-recorder.cjs.js.gz +0 -0
- package/dist/sailfish-recorder.es.js +1 -1
- package/dist/sailfish-recorder.es.js.br +0 -0
- package/dist/sailfish-recorder.es.js.gz +0 -0
- package/dist/sailfish-recorder.umd.js +1 -1
- package/dist/sailfish-recorder.umd.js.br +0 -0
- package/dist/sailfish-recorder.umd.js.gz +0 -0
- package/dist/session.js +12 -0
- package/dist/types/inAppReportIssueModal.d.ts +3 -1
- package/dist/types/index.d.ts +1 -11
- package/dist/types/runtimeEnv.d.ts +4 -0
- package/dist/types/session.d.ts +1 -0
- package/dist/websocket.js +68 -35
- package/package.json +1 -1
|
@@ -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(
|
|
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
|
|
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;
|