Files
korrithwebsite/apps/web/plugins/session-journal/session-journal-client.js
T

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 });
}