472 lines
12 KiB
JavaScript
472 lines
12 KiB
JavaScript
const EVENT = 'session-journal:event';
|
|
const MAX_TEXT = 2000;
|
|
const MAX_BATCH = 50;
|
|
const FLUSH_INTERVAL_MS = 1000;
|
|
|
|
let queue = [];
|
|
let flushTimer = null;
|
|
let connected = false;
|
|
|
|
function flushQueue() {
|
|
if (flushTimer) {
|
|
clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
}
|
|
if (!queue.length || !import.meta.hot || !connected) {
|
|
return;
|
|
}
|
|
const batch = queue;
|
|
queue = [];
|
|
try {
|
|
import.meta.hot.send(EVENT, { batch });
|
|
} catch {}
|
|
}
|
|
|
|
function send(kind, data) {
|
|
if (!import.meta.hot) {
|
|
return;
|
|
}
|
|
queue.push({ kind, t: Date.now(), ...data });
|
|
if (connected && queue.length >= MAX_BATCH) {
|
|
flushQueue();
|
|
} else if (!flushTimer) {
|
|
flushTimer = setTimeout(flushQueue, FLUSH_INTERVAL_MS);
|
|
}
|
|
}
|
|
|
|
if (import.meta.hot) {
|
|
import.meta.hot.on('vite:ws:connect', () => {
|
|
connected = true;
|
|
flushQueue();
|
|
});
|
|
import.meta.hot.on('vite:ws:disconnect', () => {
|
|
connected = false;
|
|
});
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
flushQueue();
|
|
}
|
|
});
|
|
window.addEventListener('pagehide', flushQueue);
|
|
|
|
function truncate(value) {
|
|
if (value == null) {
|
|
return '';
|
|
}
|
|
const s = String(value);
|
|
return s.length > MAX_TEXT ? s.slice(0, MAX_TEXT) + '...' : s;
|
|
}
|
|
|
|
function isSensitiveName(name) {
|
|
return /password|secret|token|apikey|key|auth|credential|cookie/i.test(String(name || ''));
|
|
}
|
|
|
|
function sanitizeUrl(value) {
|
|
try {
|
|
const url = new URL(String(value), location.href);
|
|
for (const key of [...url.searchParams.keys()]) {
|
|
if (isSensitiveName(key)) {
|
|
url.searchParams.set(key, '[redacted]');
|
|
}
|
|
}
|
|
return truncate(url.toString());
|
|
} catch {
|
|
return truncate(value);
|
|
}
|
|
}
|
|
|
|
function summarizeValue(name, value) {
|
|
if (isSensitiveName(name)) {
|
|
const length = value == null ? 0 : String(value).length;
|
|
return '[redacted:length=' + length + ']';
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => summarizeValue(name, item));
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
return summarizeObject(value);
|
|
}
|
|
return truncate(value);
|
|
}
|
|
|
|
function summarizeObject(value) {
|
|
const out = {};
|
|
for (const [key, val] of Object.entries(value || {})) {
|
|
out[key] = summarizeValue(key, val);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function summarizeRequestBody(body) {
|
|
if (body == null) {
|
|
return null;
|
|
}
|
|
if (body instanceof FormData) {
|
|
const out = {};
|
|
for (const [key, val] of body.entries()) {
|
|
out[key] = val instanceof File
|
|
? { fileName: truncate(val.name), type: val.type || null, size: val.size }
|
|
: summarizeValue(key, val);
|
|
}
|
|
return out;
|
|
}
|
|
if (body instanceof URLSearchParams) {
|
|
const out = {};
|
|
for (const [key, val] of body.entries()) out[key] = summarizeValue(key, val);
|
|
return out;
|
|
}
|
|
if (typeof body === 'string') {
|
|
try {
|
|
return summarizeObject(JSON.parse(body));
|
|
} catch {
|
|
return truncate(body);
|
|
}
|
|
}
|
|
if (body && typeof body === 'object' && body.constructor === Object) {
|
|
return summarizeObject(body);
|
|
}
|
|
return '[' + Object.prototype.toString.call(body).slice(8, -1) + ' body]';
|
|
}
|
|
|
|
function isSensitive(el) {
|
|
if (!el) {
|
|
return false;
|
|
}
|
|
if (el.type === 'password') {
|
|
return true;
|
|
}
|
|
return isSensitiveName(el.name || el.id || el.getAttribute('autocomplete') || '');
|
|
}
|
|
|
|
function compactText(value) {
|
|
return truncate(String(value || '').replace(/\s+/g, ' ').trim());
|
|
}
|
|
|
|
function labelFromLabelledBy(el) {
|
|
const ids = (el.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean);
|
|
if (!ids.length) {
|
|
return '';
|
|
}
|
|
const parts = ids
|
|
.map((id) => document.getElementById(id))
|
|
.filter(Boolean)
|
|
.map((node) => compactText(node.textContent));
|
|
return parts.filter(Boolean).join(' ');
|
|
}
|
|
|
|
function labelFromForAttr(el) {
|
|
if (!el.id) {
|
|
return '';
|
|
}
|
|
try {
|
|
const escapedId = window.CSS && CSS.escape ? CSS.escape(el.id) : el.id;
|
|
const labelEl = document.querySelector('label[for="' + escapedId + '"]');
|
|
return labelEl ? compactText(labelEl.textContent) : '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function labelFromWrappingLabel(el) {
|
|
let cur = el.parentElement;
|
|
for (let i = 0; i < 4 && cur; i++) {
|
|
if (cur.tagName === 'LABEL') {
|
|
const clone = cur.cloneNode(true);
|
|
for (const inner of clone.querySelectorAll('input, textarea, select')) {
|
|
inner.remove();
|
|
}
|
|
return compactText(clone.textContent);
|
|
}
|
|
cur = cur.parentElement;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function fieldLabel(el) {
|
|
if (!el || el.nodeType !== 1) {
|
|
return null;
|
|
}
|
|
const sources = [
|
|
el.getAttribute('name'),
|
|
labelFromLabelledBy(el),
|
|
el.getAttribute('aria-label'),
|
|
labelFromForAttr(el),
|
|
labelFromWrappingLabel(el),
|
|
el.id,
|
|
el.getAttribute('placeholder'),
|
|
];
|
|
for (const candidate of sources) {
|
|
const trimmed = candidate ? String(candidate).trim() : '';
|
|
if (trimmed) {
|
|
return compactText(trimmed);
|
|
}
|
|
}
|
|
const type = (el.getAttribute('type') || el.tagName || '').toLowerCase();
|
|
return type ? '[' + type + ']' : null;
|
|
}
|
|
|
|
function fieldValue(el) {
|
|
if (!el || !('value' in el)) {
|
|
return { value: '', length: 0, redacted: false };
|
|
}
|
|
const raw = el.value == null ? '' : String(el.value);
|
|
if (isSensitive(el)) {
|
|
return { value: '[redacted:length=' + raw.length + ']', length: raw.length, redacted: true };
|
|
}
|
|
return { value: truncate(raw), length: raw.length, redacted: false };
|
|
}
|
|
|
|
function describeElement(el) {
|
|
if (!el || el.nodeType !== 1) {
|
|
return null;
|
|
}
|
|
const tag = el.tagName.toLowerCase();
|
|
const role = el.getAttribute('role') || null;
|
|
const ariaLabel = el.getAttribute('aria-label') || null;
|
|
const name = el.getAttribute('name') || null;
|
|
const type = el.getAttribute('type') || null;
|
|
const id = el.id || null;
|
|
const placeholder = el.getAttribute('placeholder') || null;
|
|
const isInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName);
|
|
const valueInfo = isInput ? fieldValue(el) : null;
|
|
const text = isSensitive(el) ? '' : truncate(el.textContent || '');
|
|
return {
|
|
tag,
|
|
role,
|
|
ariaLabel,
|
|
name,
|
|
type,
|
|
id,
|
|
placeholder,
|
|
label: isInput ? fieldLabel(el) : null,
|
|
value: valueInfo ? valueInfo.value : null,
|
|
valueLength: valueInfo ? valueInfo.length : 0,
|
|
text,
|
|
};
|
|
}
|
|
|
|
function findInteractive(el) {
|
|
let cur = el;
|
|
for (let i = 0; i < 6 && cur; i++) {
|
|
if (cur.nodeType !== 1) { cur = cur.parentNode; continue; }
|
|
const tag = cur.tagName;
|
|
if (tag === 'BUTTON' || tag === 'A' || tag === 'INPUT' ||
|
|
tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'LABEL' ||
|
|
cur.hasAttribute('role') || cur.hasAttribute('onclick')) {
|
|
return cur;
|
|
}
|
|
cur = cur.parentNode;
|
|
}
|
|
return el;
|
|
}
|
|
|
|
document.addEventListener('click', (e) => {
|
|
send('click', { element: describeElement(findInteractive(e.target)) });
|
|
}, true);
|
|
|
|
document.addEventListener('submit', (e) => {
|
|
const form = e.target;
|
|
const fields = [];
|
|
if (form && form.elements) {
|
|
for (const el of form.elements) {
|
|
const label = fieldLabel(el);
|
|
if (!label || !('value' in el)) {
|
|
continue;
|
|
}
|
|
const v = fieldValue(el);
|
|
fields.push({
|
|
label,
|
|
type: el.type || el.tagName.toLowerCase(),
|
|
value: v.value,
|
|
length: v.length,
|
|
redacted: v.redacted,
|
|
});
|
|
}
|
|
}
|
|
send('submit', { formId: form.id || null, action: form.action || null, fields });
|
|
}, true);
|
|
|
|
document.addEventListener('focus', (e) => {
|
|
const target = e.target;
|
|
if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
|
|
return;
|
|
}
|
|
send('focus', { element: describeElement(target) });
|
|
}, true);
|
|
|
|
document.addEventListener('change', (e) => {
|
|
const target = e.target;
|
|
if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
|
|
return;
|
|
}
|
|
if (target.type === 'file') {
|
|
return;
|
|
}
|
|
send('change', { element: describeElement(target) });
|
|
}, true);
|
|
|
|
document.addEventListener('blur', (e) => {
|
|
const target = e.target;
|
|
if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
|
|
return;
|
|
}
|
|
if (!('value' in target) || !target.value) {
|
|
return;
|
|
}
|
|
send('blur', { element: describeElement(target) });
|
|
}, true);
|
|
|
|
const nativeFetch = window.fetch.bind(window);
|
|
window.fetch = async (input, init = {}) => {
|
|
const started = performance.now();
|
|
const url = typeof input === 'string' ? input : input?.url;
|
|
const method = (init?.method || input?.method || 'GET').toUpperCase();
|
|
const requestBody = summarizeRequestBody(init?.body);
|
|
try {
|
|
const response = await nativeFetch(input, init);
|
|
const requestUrl = url || response.url;
|
|
if (!response.ok) {
|
|
let body = '';
|
|
try { body = await response.clone().text(); } catch {}
|
|
send('network.error', {
|
|
method,
|
|
url: sanitizeUrl(requestUrl),
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
requestBody,
|
|
response: truncate(body),
|
|
durationMs: Math.round(performance.now() - started),
|
|
});
|
|
}
|
|
return response;
|
|
} catch (err) {
|
|
send('network.error', {
|
|
method,
|
|
url: sanitizeUrl(url || ''),
|
|
requestBody,
|
|
message: truncate(err && err.message ? err.message : String(err)),
|
|
durationMs: Math.round(performance.now() - started),
|
|
});
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
const NativeXMLHttpRequest = window.XMLHttpRequest;
|
|
window.XMLHttpRequest = function SessionJournalXMLHttpRequest() {
|
|
const xhr = new NativeXMLHttpRequest();
|
|
let method = 'GET';
|
|
let url = '';
|
|
let requestBody = null;
|
|
let started = 0;
|
|
|
|
const originalOpen = xhr.open;
|
|
xhr.open = function (nextMethod, nextUrl, ...rest) {
|
|
method = String(nextMethod || 'GET').toUpperCase();
|
|
url = String(nextUrl || '');
|
|
return originalOpen.call(xhr, nextMethod, nextUrl, ...rest);
|
|
};
|
|
|
|
const originalSend = xhr.send;
|
|
xhr.send = function (body) {
|
|
started = performance.now();
|
|
requestBody = summarizeRequestBody(body);
|
|
return originalSend.call(xhr, body);
|
|
};
|
|
|
|
xhr.addEventListener('loadend', () => {
|
|
if (xhr.status >= 400 || xhr.status === 0) {
|
|
let response = '';
|
|
try { response = xhr.responseText || ''; } catch {}
|
|
send('network.error', {
|
|
transport: 'xhr',
|
|
method,
|
|
url: sanitizeUrl(url),
|
|
status: xhr.status,
|
|
statusText: xhr.statusText,
|
|
requestBody,
|
|
response: truncate(response),
|
|
durationMs: Math.round(performance.now() - started),
|
|
});
|
|
}
|
|
});
|
|
|
|
return xhr;
|
|
};
|
|
window.XMLHttpRequest.prototype = NativeXMLHttpRequest.prototype;
|
|
|
|
send('load', { url: location.href, title: document.title });
|
|
|
|
const origPush = history.pushState;
|
|
history.pushState = function (...args) {
|
|
const result = origPush.apply(this, args);
|
|
send('navigate', { url: location.href, via: 'pushState' });
|
|
return result;
|
|
};
|
|
const origReplace = history.replaceState;
|
|
history.replaceState = function (...args) {
|
|
const result = origReplace.apply(this, args);
|
|
send('navigate', { url: location.href, via: 'replaceState' });
|
|
return result;
|
|
};
|
|
window.addEventListener('popstate', () => {
|
|
send('navigate', { url: location.href, via: 'popstate' });
|
|
});
|
|
|
|
for (const level of ['error', 'warn']) {
|
|
const original = console[level];
|
|
console[level] = (...args) => {
|
|
try {
|
|
const text = args.map((arg) => {
|
|
if (arg instanceof Error) {
|
|
return arg.stack || arg.message;
|
|
}
|
|
if (typeof arg === 'object') {
|
|
try { return JSON.stringify(arg); } catch { return String(arg); }
|
|
}
|
|
return String(arg);
|
|
}).join(' ');
|
|
send('console.' + level, { text: truncate(text) });
|
|
} catch {}
|
|
original.apply(console, args);
|
|
};
|
|
}
|
|
|
|
window.addEventListener('error', (e) => {
|
|
send('window.error', {
|
|
message: truncate(e.message),
|
|
source: e.filename || null,
|
|
line: e.lineno || null,
|
|
col: e.colno || null,
|
|
stack: e.error && e.error.stack ? truncate(e.error.stack) : null,
|
|
});
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', (e) => {
|
|
const reason = e.reason;
|
|
send('unhandledrejection', {
|
|
message: truncate(reason && reason.message ? reason.message : String(reason)),
|
|
stack: reason && reason.stack ? truncate(reason.stack) : null,
|
|
});
|
|
});
|
|
|
|
const root = document.getElementById('root');
|
|
if (root) {
|
|
let blankTimer = null;
|
|
const check = () => {
|
|
if (root.children.length === 0) {
|
|
if (!blankTimer) {
|
|
blankTimer = setTimeout(() => {
|
|
send('root.empty', { url: location.href });
|
|
blankTimer = null;
|
|
}, 300);
|
|
}
|
|
} else if (blankTimer) {
|
|
clearTimeout(blankTimer);
|
|
blankTimer = null;
|
|
}
|
|
};
|
|
new MutationObserver(check).observe(root, { childList: true });
|
|
}
|