// ==UserScript== // @name XHR/Fetch/WS Logger Pro // @namespace http://tampermonkey.net/ // @version 2.2.0 // @description XHR + Fetch + WebSocket logger with replay, diff, sensitive detection. Shadow DOM isolated. CSP-proof. Firefox + Chrome. // @author TekMonts // @match *://*/* // @grant unsafeWindow // @grant GM_setClipboard // @grant GM_setValue // @grant GM_getValue // @run-at document-start // @noframes // ==/UserScript== (function () { 'use strict'; /* ========================================================= * 🛡 EARLY BAILOUT: Cloudflare challenge / bot-check pages * Detect and bail out RIGHT BEFORE install hook. * ========================================================= */ const isCloudflareChallenge = () => { try { const h = location.hostname; // CF-owned domains that serve challenges/widgets if (/(^|\.)(cloudflare\.com|cloudflareinsights\.com)$/i.test(h)) return true; if (h === 'challenges.cloudflare.com') return true; // In iframe hosted by CF (Turnstile widget) if (window !== window.top) { try { if (/cloudflare/i.test(document.referrer)) return true; } catch (_) {} } // CF interstitial pages: Just a moment… or meta const title = (document.title || '').toLowerCase(); if (title.includes('just a moment') || title.includes('attention required')) return true; // CF injects this marker on challenge pages if (document.getElementById('challenge-running') || document.getElementById('cf-challenge-running') || document.querySelector('meta[http-equiv="refresh"][content*="challenge"]')) return true; } catch (_) {} return false; }; if (isCloudflareChallenge()) { console.log('[XHR Logger] Cloudflare challenge detected — skipping to avoid interference'); return; } /* ========================================================= * ⚙ CONFIG + STATE * ========================================================= */ const CONFIG = { MAX_LOGS: 2000, MAX_BODY_BYTES: 200000, RENDER_CAP: 400, WS_MAX_FRAMES: 500, WS_FRAME_BYTES: 20000, AUTO_CLEAR_MS: 0, }; const state = { store: [], paused: false, skipBody: false, groupByDomain: true, selectedId: null, baselineId: null, filter: '', hookOK: false, hookMethod: 'pending', hookError: null, autoClearTimer: null, autoClearNext: 0, hookActivatedAt: 0, panelPos: null, panelSize: null, fabPos: null, }; try { state.panelPos = JSON.parse(GM_getValue('panelPos', 'null')); state.panelSize = JSON.parse(GM_getValue('panelSize', 'null')); state.fabPos = JSON.parse(GM_getValue('fabPos', 'null')); } catch (_) {} // Hoisted early — these flags can be touched by hooks/UI before the // sections that declare them execute (TDZ avoidance). let renderPending = false; let eventsWired = false; /* ========================================================= * 🧩 ENV DETECTION * ========================================================= */ const W = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; const isFirefox = (typeof exportFunction === 'function') && (typeof cloneInto === 'function'); const exportFn = (fn) => isFirefox ? exportFunction(fn, W) : fn; /* ========================================================= * 🛠 UTILITIES * ========================================================= */ const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8); const truncate = (s, max) => { max = max || CONFIG.MAX_BODY_BYTES; if (typeof s !== 'string') return s; return s.length > max ? s.slice(0, max) + '\n…[truncated ' + (s.length - max) + ' chars]' : s; }; const parseHeaders = (raw) => { const h = {}; if (!raw) return h; raw.trim().split(/[\r\n]+/).forEach(line => { const parts = line.split(': '); const key = parts.shift(); const val = parts.join(': '); if (key) h[key] = val; }); return h; }; const decodeBinary = (buf) => { try { let view; if (buf instanceof ArrayBuffer) view = new Uint8Array(buf); else if (ArrayBuffer.isView(buf)) view = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); else return '[binary unknown]'; const len = view.byteLength; const typeName = (buf.constructor && buf.constructor.name) || 'binary'; try { const text = new TextDecoder('utf-8', { fatal: false }).decode(view); let nonPrintable = 0; const sampleLen = Math.min(text.length, 1024); for (let i = 0; i < sampleLen; i++) { const c = text.charCodeAt(i); if ((c < 0x20 && c !== 0x09 && c !== 0x0A && c !== 0x0D) || c === 0xFFFD) nonPrintable++; } if (nonPrintable / Math.max(sampleLen, 1) < 0.05) { return truncate(text); } } catch (_) {} const hexLen = Math.min(64, len); const hex = Array.from(view.slice(0, hexLen)) .map(b => b.toString(16).padStart(2, '0')) .join(' '); return `[${typeName} ${len}B] hex: ${hex}${len > hexLen ? '…' : ''}`; } catch (_) { return '[binary unreadable]'; } }; const serializeFormData = (fd) => { try { const parts = []; let count = 0; for (const [k, v] of fd.entries()) { if (count++ > 50) { parts.push('…[+more]'); break; } if (typeof v === 'string') parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(v)); else if (v && v.name != null) parts.push(`${k}=[File ${v.name} ${v.size}B ${v.type || ''}]`); else parts.push(k + '=[?]'); } return '[FormData] ' + truncate(parts.join('&')); } catch (_) { return '[FormData]'; } }; const encodeBody = (body) => { if (body == null) return null; if (typeof body === 'string') return truncate(body); if (!body.constructor) return '[unknown]'; const n = body.constructor.name; if (n === 'FormData') return serializeFormData(body); if (n === 'Blob') return `[Blob ${body.size}B type=${body.type || '?'}]`; if (n === 'URLSearchParams') return truncate(String(body)); if (n === 'ArrayBuffer' || ArrayBuffer.isView(body)) return decodeBinary(body); if (n === 'ReadableStream') return '[ReadableStream]'; if (n === 'Document') return truncate(new XMLSerializer().serializeToString(body)); return '[' + n + ']'; }; const hostnameOf = (url) => { try { return new URL(url, location.href).hostname; } catch (_) { return url && url.startsWith && url.startsWith('ws') ? 'ws' : '(local)'; } }; /* ========================================================= * 🔐 SENSITIVE DETECTION * ========================================================= */ const SENSITIVE_HEADERS = /^(authorization|cookie|set-cookie|x-auth|x-auth-token|x-api-key|x-csrf-token|x-xsrf-token|api-key|bearer|proxy-authorization|www-authenticate)$/i; const SENSITIVE_BODY_KEY = /\b(password|passwd|pwd|api[_-]?key|access[_-]?token|refresh[_-]?token|secret|client[_-]?secret|jwt|bearer|ssn|credit[_-]?card|cvv|private[_-]?key)\b/i; const flagSensitive = (log) => { const flags = []; const scanHeaders = (obj, where) => { if (!obj) return; for (const k of Object.keys(obj)) { if (SENSITIVE_HEADERS.test(k)) flags.push(where + ':' + k.toLowerCase()); } }; scanHeaders(log.requestHeaders, 'req'); scanHeaders(log.responseHeaders, 'res'); const bodyMix = [log.requestBody, log.responseBody].filter(v => typeof v === 'string').join(' '); if (bodyMix && SENSITIVE_BODY_KEY.test(bodyMix)) flags.push('body'); log.sensitive = flags.length > 0; log.sensitiveFlags = flags; }; /* ========================================================= * 📥 LOG PIPELINE * ========================================================= */ const pushLog = (log) => { if (state.paused) return; try { if (log.url && !/^(https?|wss?|data|blob|file):/i.test(log.url)) { log.url = new URL(log.url, location.href).href; } } catch (_) {} log.hostname = hostnameOf(log.url); if (state.skipBody) { log.requestBody = log.requestBody ? '[skipped]' : null; log.responseBody = '[skipped]'; } flagSensitive(log); state.store.push(log); if (state.store.length > CONFIG.MAX_LOGS) { const over = state.store.length - CONFIG.MAX_LOGS; let dropped = 0, i = 0; while (dropped < over && i < state.store.length) { if (state.store[i].id !== state.selectedId && state.store[i].id !== state.baselineId) { state.store.splice(i, 1); dropped++; } else { i++; } } } scheduleRender(); }; /* ========================================================= * 🪝 XHR HOOK * ========================================================= */ const xhrLogMap = new WeakMap(); const installXHRHook = () => { const OrigXHR = W.XMLHttpRequest; if (!OrigXHR || !OrigXHR.prototype) throw new Error('No XHR in unsafeWindow'); const proto = OrigXHR.prototype; const origOpen = proto.open; const origSend = proto.send; const origSetHeader = proto.setRequestHeader; const newOpen = exportFn(function (method, url) { try { xhrLogMap.set(this, { id: uid(), type: 'xhr', method: (method || 'GET').toUpperCase(), url: String(url), requestHeaders: {}, time: new Date().toISOString(), startedAt: Date.now() }); } catch (_) {} return origOpen.apply(this, arguments); }); const newSetHeader = exportFn(function (k, v) { try { const log = xhrLogMap.get(this); if (log) log.requestHeaders[k] = String(v); } catch (_) {} return origSetHeader.apply(this, arguments); }); const newSend = exportFn(function (body) { try { const log = xhrLogMap.get(this); if (log) { log.requestBody = encodeBody(body); const xhr = this; xhr.addEventListener('loadend', exportFn(function () { try { log.status = xhr.status; log.statusText = xhr.statusText; log.duration = Date.now() - log.startedAt; try { log.responseHeaders = parseHeaders(xhr.getAllResponseHeaders()); } catch (_) { log.responseHeaders = {}; } try { if (xhr.responseType === '' || xhr.responseType === 'text') { log.responseBody = truncate(xhr.responseText || ''); } else if (xhr.responseType === 'json') { log.responseBody = truncate(JSON.stringify(xhr.response)); } else { log.responseBody = '[' + (xhr.responseType || 'binary') + ']'; } } catch (_) { log.responseBody = '[unreadable]'; } pushLog(log); } catch (_) {} })); } } catch (_) {} return origSend.apply(this, arguments); }); try { proto.open = newOpen; proto.setRequestHeader = newSetHeader; proto.send = newSend; } catch (_) { Object.defineProperty(proto, 'open', { value: newOpen, writable: true, configurable: true }); Object.defineProperty(proto, 'setRequestHeader', { value: newSetHeader, writable: true, configurable: true }); Object.defineProperty(proto, 'send', { value: newSend, writable: true, configurable: true }); } // Stealth: make hooked XHR methods look native to fingerprint checks try { newOpen.toString = exportFn(() => origOpen.toString()); newSetHeader.toString = exportFn(() => origSetHeader.toString()); newSend.toString = exportFn(() => origSend.toString()); } catch (_) {} return true; }; /* ========================================================= * 🪝 FETCH HOOK * ========================================================= */ const installFetchHook = () => { const origFetch = W.fetch; if (!origFetch) return false; const newFetch = exportFn(function (input, init) { const log = { id: uid(), type: 'fetch', time: new Date().toISOString(), startedAt: Date.now(), requestHeaders: {} }; try { const isReq = (typeof Request !== 'undefined') && (input instanceof Request); log.url = isReq ? input.url : String(input); log.method = ((init && init.method) || (isReq && input.method) || 'GET').toUpperCase(); const hdrs = (init && init.headers) || (isReq && input.headers); if (hdrs) { if (typeof hdrs.forEach === 'function') { hdrs.forEach(exportFn(function (v, k) { log.requestHeaders[k] = String(v); })); } else if (typeof hdrs === 'object') { try { Object.keys(hdrs).forEach(k => log.requestHeaders[k] = String(hdrs[k])); } catch (_) {} } } if (init && init.body != null) { log.requestBody = encodeBody(init.body); } } catch (_) {} const promise = origFetch.apply(this, arguments); promise.then(exportFn(function (res) { try { const clone = res.clone(); log.status = clone.status; log.statusText = clone.statusText; log.duration = Date.now() - log.startedAt; log.responseHeaders = {}; clone.headers.forEach(exportFn(function (v, k) { log.responseHeaders[k] = v; })); clone.text().then(exportFn(function (t) { log.responseBody = truncate(t); pushLog(log); }), exportFn(function () { log.responseBody = '[unreadable]'; pushLog(log); })); } catch (_) { pushLog(log); } }), exportFn(function (err) { log.status = 0; log.error = String(err); log.duration = Date.now() - log.startedAt; pushLog(log); })); return promise; }); // Stealth: make hooked fetch look native to fingerprint checks // (CF, Akamai, PerimeterX inspect Function.prototype.toString) try { const nativeStr = origFetch.toString(); newFetch.toString = exportFn(() => nativeStr); } catch (_) {} try { W.fetch = newFetch; } catch (_) { Object.defineProperty(W, 'fetch', { value: newFetch, writable: true, configurable: true }); } return true; }; /* ========================================================= * 🪝 WEBSOCKET HOOK * ========================================================= */ const wsLogMap = new WeakMap(); const installWSHook = () => { const OrigWS = W.WebSocket; if (!OrigWS) return false; // Bail if we can't safely wrap (e.g. Object.freeze'd) try { const desc = Object.getOwnPropertyDescriptor(W, 'WebSocket'); if (desc && desc.configurable === false && desc.writable === false) return false; } catch (_) { return false; } const proto = OrigWS.prototype; const origSend = proto.send; const newSend = exportFn(function (data) { try { let log = wsLogMap.get(this); if (!log) { log = createWSLog(this.url || '(unknown)'); wsLogMap.set(this, log); state.store.push(log); } pushWSFrame(log, 'out', data); scheduleRender(); } catch (_) {} return origSend.apply(this, arguments); }); try { proto.send = newSend; } catch (_) { Object.defineProperty(proto, 'send', { value: newSend, writable: true, configurable: true }); } function HookedWS(url, protocols) { const ws = protocols !== undefined ? new OrigWS(url, protocols) : new OrigWS(url); let log = createWSLog(String(url)); wsLogMap.set(ws, log); state.store.push(log); flagSensitive(log); scheduleRender(); ws.addEventListener('open', exportFn(function () { log.status = 101; log.statusText = 'Switching Protocols'; scheduleRender(); })); ws.addEventListener('message', exportFn(function (e) { try { pushWSFrame(log, 'in', e.data); scheduleRender(); } catch (_) {} })); ws.addEventListener('close', exportFn(function (e) { log.closed = true; log.closeCode = e.code; log.closeReason = e.reason; log.duration = Date.now() - log.startedAt; scheduleRender(); })); ws.addEventListener('error', exportFn(function () { log.error = 'WebSocket error'; scheduleRender(); })); return ws; } // Make HookedWS appear as native WebSocket to anti-bot checks HookedWS.prototype = OrigWS.prototype; try { Object.defineProperty(HookedWS, 'name', { value: 'WebSocket', configurable: true }); HookedWS.toString = () => OrigWS.toString(); } catch (_) {} try { ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'].forEach(k => { if (k in OrigWS) HookedWS[k] = OrigWS[k]; }); } catch (_) {} const wrappedCtor = isFirefox ? exportFn(HookedWS) : HookedWS; try { wrappedCtor.prototype = OrigWS.prototype; } catch (_) {} try { ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'].forEach(k => { if (k in OrigWS) wrappedCtor[k] = OrigWS[k]; }); } catch (_) {} try { W.WebSocket = wrappedCtor; } catch (_) { Object.defineProperty(W, 'WebSocket', { value: wrappedCtor, writable: true, configurable: true }); } return true; }; const createWSLog = (url) => ({ id: uid(), type: 'ws', method: 'WS', url: String(url), requestHeaders: {}, responseHeaders: {}, time: new Date().toISOString(), startedAt: Date.now(), frames: [] }); const pushWSFrame = (log, dir, data) => { if (state.paused) return; if (log.frames.length >= CONFIG.WS_MAX_FRAMES) log.frames.shift(); let preview; try { if (data == null) preview = ''; else if (typeof data === 'string') preview = truncate(data, CONFIG.WS_FRAME_BYTES); else if (data.byteLength != null) preview = '[binary ' + data.byteLength + 'B]'; else preview = String(data); } catch (_) { preview = '[unreadable]'; } log.frames.push({ dir, time: Date.now(), data: preview }); }; /* ========================================================= * 🎬 INSTALL ALL HOOKS * ========================================================= */ (function installAll() { try { installXHRHook(); installFetchHook(); installWSHook(); state.hookOK = true; state.hookActivatedAt = Date.now(); state.hookMethod = isFirefox ? 'unsafeWindow+exportFunction (FF)' : 'unsafeWindow direct'; console.log('[XHR Logger] Hooks installed via', state.hookMethod); } catch (e) { state.hookError = String(e); console.error('[XHR Logger] Hook install failed:', e); } })(); /* ========================================================= * 👁 SAFETY NET: PerformanceObserver * ========================================================= */ (function installPerformanceObserver() { if (typeof PerformanceObserver === 'undefined') return; try { const po = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.initiatorType !== 'fetch' && entry.initiatorType !== 'xmlhttprequest') continue; const absTime = performance.timeOrigin + entry.startTime; if (state.hookActivatedAt && absTime >= state.hookActivatedAt - 50) continue; let url; try { url = new URL(entry.name, location.href).href; } catch (_) { url = entry.name; } const dup = state.store.some(l => l.url === url && Math.abs(l.startedAt - absTime) < 200 ); if (dup) continue; const log = { id: uid(), type: entry.initiatorType === 'fetch' ? 'fetch' : 'xhr', method: '?', url, requestHeaders: {}, responseHeaders: {}, requestBody: null, responseBody: '[captured via PerformanceObserver — fired before hook installed. Headers/body unavailable. Reload page if you need full capture.]', time: new Date(absTime).toISOString(), startedAt: absTime, duration: Math.round(entry.duration || 0), status: entry.responseStatus || 0, statusText: '', _observed: true }; pushLog(log); } }); po.observe({ type: 'resource', buffered: true }); } catch (e) { console.warn('[XHR Logger] PerformanceObserver setup failed:', e); } })(); /* ========================================================= * 🔁 REPLAY ENGINE * ========================================================= */ const replay = (log) => { if (!log) throw new Error('replay: no log given'); if (log.type === 'ws') throw new Error('replay: WebSocket replay not supported'); const headers = {}; const FORBIDDEN = /^(host|content-length|connection|accept-encoding|cookie|origin|referer|user-agent|sec-|proxy-|transfer-encoding|te|upgrade|keep-alive|expect|trailer)/i; for (const [k, v] of Object.entries(log.requestHeaders || {})) { if (!FORBIDDEN.test(k)) headers[k] = v; } const opts = { method: log.method, headers, credentials: 'include', mode: 'cors', }; if (!['GET', 'HEAD'].includes(log.method) && log.requestBody && typeof log.requestBody === 'string' && !log.requestBody.startsWith('[')) { opts.body = log.requestBody; } return fetch(log.url, opts).then(async res => { const text = await res.text(); return { status: res.status, statusText: res.statusText, headers: Object.fromEntries(res.headers.entries()), body: text }; }); }; /* ========================================================= * 🔬 DIFF ENGINE * ========================================================= */ const safeParse = (s) => { if (typeof s !== 'string') return s; try { return JSON.parse(s); } catch (_) { return s; } }; const diffValues = (a, b, path, acc) => { if (a === b) return; if (typeof a !== typeof b) { acc.push({ path, type: 'type', from: a, to: b }); return; } if (a === null || b === null || typeof a !== 'object') { acc.push({ path, type: 'value', from: a, to: b }); return; } if (Array.isArray(a) && Array.isArray(b)) { const n = Math.max(a.length, b.length); for (let i = 0; i < n; i++) { const p = path + '[' + i + ']'; if (i >= a.length) acc.push({ path: p, type: 'added', to: b[i] }); else if (i >= b.length) acc.push({ path: p, type: 'removed', from: a[i] }); else diffValues(a[i], b[i], p, acc); } return; } const keys = new Set([...Object.keys(a), ...Object.keys(b)]); for (const k of keys) { const p = path ? path + '.' + k : k; if (!(k in a)) acc.push({ path: p, type: 'added', to: b[k] }); else if (!(k in b)) acc.push({ path: p, type: 'removed', from: a[k] }); else diffValues(a[k], b[k], p, acc); } }; const diff = (a, b) => { if (typeof a === 'string') a = state.store.find(l => l.id === a); if (typeof b === 'string') b = state.store.find(l => l.id === b); if (!a || !b) throw new Error('diff: log(s) not found'); const acc = []; diffValues(safeParse(a.responseBody), safeParse(b.responseBody), '', acc); return acc; }; /* ========================================================= * 🎛 AUTO-CLEAR * ========================================================= */ const setAutoClear = (ms) => { CONFIG.AUTO_CLEAR_MS = ms; if (state.autoClearTimer) { clearInterval(state.autoClearTimer); state.autoClearTimer = null; } state.autoClearNext = 0; if (ms > 0) { state.autoClearNext = Date.now() + ms; state.autoClearTimer = setInterval(() => { state.store = state.store.filter(l => l.id === state.selectedId || l.id === state.baselineId ); state.autoClearNext = Date.now() + ms; scheduleRender(); }, ms); } }; /* ========================================================= * 🌐 PUBLIC API * ========================================================= */ const api = { pause: () => { state.paused = true; updateStatusUI(); }, resume: () => { state.paused = false; updateStatusUI(); }, clear: () => { state.store.length = 0; state.selectedId = null; state.baselineId = null; scheduleRender(); }, logs: () => state.store.slice(), replay, diff, setSkipBody: (v) => { state.skipBody = !!v; }, setMaxLogs: (n) => { CONFIG.MAX_LOGS = n; }, setAutoClear, config: CONFIG, state: () => ({ ...state, store: undefined }) }; try { if (isFirefox) W.__XHR_LOGGER__ = cloneInto(api, W, { cloneFunctions: true }); else W.__XHR_LOGGER__ = api; } catch (e) { console.warn('[XHR Logger] Cannot expose global:', e); } window.__XHR_LOGGER__ = api; /* ========================================================= * 🎨 SHADOW DOM UI * ========================================================= */ const CSS = ` :host { all: initial !important; } * { box-sizing: border-box; font-family: ui-monospace, Menlo, Consolas, monospace; } #fab { position: fixed; z-index: 2147483646; bottom: 20px; right: 20px; width: 48px; height: 48px; border-radius: 50%; background: linear-gradient(135deg,#6366f1,#8b5cf6); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 6px 20px rgba(99,102,241,.5); font-weight: 700; font-size: 18px; user-select: none; transition: background .2s; pointer-events: auto; touch-action: none; } #fab.paused { background: linear-gradient(135deg,#ef4444,#f97316); } #fab .badge { position: absolute; top: -4px; right: -4px; background: #ef4444; color: #fff; font-size: 11px; padding: 2px 6px; border-radius: 10px; min-width: 20px; text-align: center; font-weight: 700; } #panel { position: fixed; z-index: 2147483647; width: 850px; max-width: 95vw; height: 560px; max-height: 85vh; background: #0f172a; color: #e2e8f0; border: 1px solid #334155; border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,.6); display: none; flex-direction: column; overflow: hidden; min-width: 280px; min-height: 300px; pointer-events: auto; } @media (max-width: 600px) { #panel { width: 100vw !important; max-width: 100vw !important; height: 90vh !important; max-height: 90vh !important; left: 0 !important; right: 0 !important; bottom: 0 !important; top: auto !important; border-radius: 12px 12px 0 0; } .body { flex-direction: column !important; } .list { width: 100% !important; max-height: 40% !important; border-right: none !important; border-bottom: 1px solid #334155; } .resize { display: none !important; } } #panel.open { display: flex; } .header { display: flex; align-items: center; gap: 6px; padding: 8px 10px; background: #1e293b; border-bottom: 1px solid #334155; flex-wrap: wrap; cursor: move; user-select: none; touch-action: none; } .header .title { font-weight: 700; color: #a5b4fc; flex: 1; font-size: 12px; } .header .title .ver { color: #64748b; font-weight: 400; font-size: 10px; margin-left: 4px; } .header input, .header button { cursor: auto; } .btn { background: #334155; color: #e2e8f0; border: none; padding: 4px 8px; border-radius: 5px; font-size: 11px; cursor: pointer; font-family: inherit; white-space: nowrap; } .btn:hover { background: #475569; } .btn.active { background: #6366f1; } .btn.danger { background: #7f1d1d; } .btn.danger:hover { background: #991b1b; } .btn.warn { background: #92400e; } .btn.warn:hover { background: #b45309; } .search { padding: 5px 8px; background: #0f172a; border: 1px solid #334155; color: #e2e8f0; border-radius: 5px; font-size: 11px; width: 140px; font-family: inherit; } .body { display: flex; flex: 1; overflow: hidden; } .list { width: 42%; overflow-y: auto; border-right: 1px solid #334155; } .group-hdr { padding: 4px 10px; background: #1e293b; color: #94a3b8; font-size: 10px; font-weight: 700; position: sticky; top: 0; z-index: 2; border-bottom: 1px solid #0f172a; } .item { padding: 6px 10px; border-bottom: 1px solid #1e293b; cursor: pointer; font-size: 11px; display: flex; gap: 5px; align-items: center; } .item:hover { background: #1e293b; } .item.active { background: #312e81; } .item.baseline { border-left: 3px solid #fbbf24; padding-left: 7px; } .method { padding: 1px 5px; border-radius: 3px; font-weight: 700; font-size: 9px; min-width: 40px; text-align: center; } .m-GET { background: #065f46; color: #6ee7b7; } .m-POST { background: #1e40af; color: #93c5fd; } .m-PUT { background: #92400e; color: #fcd34d; } .m-DELETE { background: #991b1b; color: #fca5a5; } .m-PATCH { background: #5b21b6; color: #c4b5fd; } .m-WS { background: #134e4a; color: #5eead4; } .url { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .status { font-weight: 700; font-size: 10px; min-width: 28px; } .s-2 { color: #6ee7b7; } .s-3 { color: #fcd34d; } .s-4 { color: #fca5a5; } .s-5 { color: #f87171; } .s-0 { color: #94a3b8; } .flag { font-size: 10px; } .detail { flex: 1; overflow-y: auto; padding: 10px; font-size: 11px; background: #020617; } .detail h4 { color: #a5b4fc; margin: 8px 0 4px; font-size: 12px; display: flex; align-items: center; gap: 6px; } .detail pre { background: #0f172a; padding: 8px; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; border: 1px solid #1e293b; margin: 0; color: #cbd5e1; max-height: 300px; } .detail .row { display: flex; gap: 6px; margin-bottom: 4px; word-break: break-all; align-items: baseline; flex-wrap: wrap; } .actions { display: flex; gap: 5px; flex-wrap: wrap; margin: 8px 0; } .empty { padding: 20px; text-align: center; color: #64748b; font-size: 12px; } .toast { position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); background: #10b981; color: #fff; padding: 8px 16px; border-radius: 6px; font-size: 12px; z-index: 2147483647; pointer-events: auto; } .diag { font-size: 10px; color: #94a3b8; padding: 5px 10px; background: #0b1120; border-top: 1px solid #1e293b; display: flex; justify-content: space-between; gap: 8px; } .diag.err { color: #fca5a5; } .sensitive-tag { background: #7f1d1d; color: #fecaca; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 700; } .frame { padding: 4px 8px; margin: 2px 0; border-radius: 4px; font-size: 11px; white-space: pre-wrap; word-break: break-all; border-left: 3px solid; } .frame.in { background: #0c2e1a; border-color: #10b981; } .frame.out { background: #1e1b4b; border-color: #6366f1; } .frame .t { color: #64748b; font-size: 10px; margin-right: 6px; } .resize { position: absolute; right: 0; bottom: 0; width: 14px; height: 14px; cursor: nwse-resize; background: linear-gradient(135deg, transparent 45%, #475569 45%, #475569 65%, transparent 65%, transparent 75%, #475569 75%); touch-action: none; } .diff-added { color: #6ee7b7; } .diff-removed { color: #fca5a5; text-decoration: line-through; } .diff-changed { color: #fcd34d; } `; /* === CREATE SHADOW HOST === */ const host = document.createElement('div'); host.id = '__xhr_logger_host__'; // Host itself is invisible 0x0; children are position:fixed so they show in viewport // pointer-events:none on host so it doesn't block clicks on page beneath host.style.cssText = 'all:initial;position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647;pointer-events:none;'; const shadow = host.attachShadow({ mode: 'open' }); // Helper: query within shadow const $ = (id) => shadow.getElementById(id); // Style const styleEl = document.createElement('style'); styleEl.textContent = CSS; shadow.appendChild(styleEl); // FAB const fab = document.createElement('div'); fab.id = 'fab'; fab.innerHTML = '🕸'; shadow.appendChild(fab); // Panel const panel = document.createElement('div'); panel.id = 'panel'; panel.innerHTML = `
🕸 XHR Loggerv2.2
No requests yet…
Select a request
`; shadow.appendChild(panel); function applyPersistedGeometry() { if (state.panelPos) { panel.style.left = state.panelPos.left + 'px'; panel.style.top = state.panelPos.top + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; } else { panel.style.right = '20px'; panel.style.bottom = '80px'; } if (state.panelSize) { panel.style.width = state.panelSize.width + 'px'; panel.style.height = state.panelSize.height + 'px'; } if (state.fabPos) { fab.style.left = state.fabPos.left + 'px'; fab.style.top = state.fabPos.top + 'px'; fab.style.right = 'auto'; fab.style.bottom = 'auto'; } } function mount() { if (document.getElementById('__xhr_logger_host__')) return; (document.body || document.documentElement).appendChild(host); applyPersistedGeometry(); wireEvents(); } if (document.body) mount(); else { document.addEventListener('DOMContentLoaded', mount); const iv = setInterval(() => { if (document.body) { mount(); clearInterval(iv); } }, 100); } /* ========================================================= * 🎯 DRAG + RESIZE + EVENT WIRING * ========================================================= */ function wireEvents() { if (eventsWired) return; eventsWired = true; // FAB: tap to toggle, drag to reposition (uses click-vs-drag detection) const setupFabDrag = () => { const persistFab = (left, top) => { state.fabPos = { left, top }; try { GM_setValue('fabPos', JSON.stringify(state.fabPos)); } catch (_) {} }; const startFabDrag = (clientX, clientY) => { const r = fab.getBoundingClientRect(); const offX = clientX - r.left, offY = clientY - r.top; let moved = false; const moveTo = (cx, cy) => { const dx = Math.abs(cx - clientX), dy = Math.abs(cy - clientY); if (!moved && (dx > 6 || dy > 6)) moved = true; if (moved) { const nx = Math.max(0, Math.min(window.innerWidth - 48, cx - offX)); const ny = Math.max(0, Math.min(window.innerHeight - 48, cy - offY)); fab.style.left = nx + 'px'; fab.style.top = ny + 'px'; fab.style.right = 'auto'; fab.style.bottom = 'auto'; } }; const finish = () => { if (moved) { const r2 = fab.getBoundingClientRect(); persistFab(r2.left, r2.top); } else { // It was a tap → toggle panel panel.classList.toggle('open'); renderList(); renderDetail(); updateDiag(); } }; return { moveTo, finish }; }; fab.addEventListener('mousedown', (e) => { e.preventDefault(); const { moveTo, finish } = startFabDrag(e.clientX, e.clientY); const move = (ev) => moveTo(ev.clientX, ev.clientY); const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); finish(); }; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); }); fab.addEventListener('touchstart', (e) => { if (e.touches.length !== 1) return; const t0 = e.touches[0]; e.preventDefault(); const { moveTo, finish } = startFabDrag(t0.clientX, t0.clientY); const move = (ev) => { if (ev.touches.length !== 1) return; ev.preventDefault(); moveTo(ev.touches[0].clientX, ev.touches[0].clientY); }; const end = () => { document.removeEventListener('touchmove', move); document.removeEventListener('touchend', end); document.removeEventListener('touchcancel', end); finish(); }; document.addEventListener('touchmove', move, { passive: false }); document.addEventListener('touchend', end); document.addEventListener('touchcancel', end); }, { passive: false }); }; setupFabDrag(); // Click delegation - listen on shadow so e.target is the actual inner element // Note: FAB tap is handled inside setupFabDrag (not here) to disambiguate from drag shadow.addEventListener('click', (e) => { const t = e.target; if (!t || t === fab || fab.contains(t)) return; if (!t.id) return; switch (t.id) { case 'close-btn': panel.classList.remove('open'); break; case 'clear-btn': state.store.length = 0; state.selectedId = null; state.baselineId = null; updateBadge(); renderList(); renderDetail(); updateDiag(); break; case 'pause': state.paused ? api.resume() : api.pause(); updateStatusUI(); renderList(); updateDiag(); break; case 'skipbody': state.skipBody = !state.skipBody; updateStatusUI(); updateDiag(); toast('Skip body: ' + (state.skipBody ? 'ON' : 'OFF')); break; case 'group': state.groupByDomain = !state.groupByDomain; updateStatusUI(); renderList(); break; case 'autoclean': { const newMs = CONFIG.AUTO_CLEAR_MS > 0 ? 0 : 60000; setAutoClear(newMs); updateStatusUI(); updateDiag(); toast(newMs ? '🧹 Auto-clean ON (60s)' : '🧹 Auto-clean OFF'); break; } case 'export-json': download('xhr-log.json', JSON.stringify(state.store, null, 2), 'application/json'); break; case 'export-curl': download('xhr-log.sh', state.store.map(toCurl).join('\n\n'), 'text/plain'); break; case 'export-text': download('xhr-log.txt', state.store.map(toText).join('\n\n'), 'text/plain'); break; } }); shadow.addEventListener('input', (e) => { if (e.target && e.target.id === 'search') { state.filter = e.target.value.toLowerCase(); renderList(); } }); // Drag (mouse + touch) const handle = $('drag-handle'); if (handle) { const startDrag = (clientX, clientY) => { const r = panel.getBoundingClientRect(); const ix = r.left, iy = r.top; const moveTo = (cx, cy) => { let nx = ix + cx - clientX; let ny = iy + cy - clientY; nx = Math.max(0, Math.min(window.innerWidth - 50, nx)); ny = Math.max(0, Math.min(window.innerHeight - 30, ny)); panel.style.left = nx + 'px'; panel.style.top = ny + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; }; const persist = () => { const r2 = panel.getBoundingClientRect(); state.panelPos = { left: r2.left, top: r2.top }; try { GM_setValue('panelPos', JSON.stringify(state.panelPos)); } catch (_) {} }; return { moveTo, persist }; }; handle.addEventListener('mousedown', (e) => { if (e.target.closest('button,input')) return; e.preventDefault(); const { moveTo, persist } = startDrag(e.clientX, e.clientY); const move = (ev) => moveTo(ev.clientX, ev.clientY); const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); persist(); }; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); }); handle.addEventListener('touchstart', (e) => { if (e.target.closest('button,input')) return; if (e.touches.length !== 1) return; const t0 = e.touches[0]; e.preventDefault(); const { moveTo, persist } = startDrag(t0.clientX, t0.clientY); const move = (ev) => { if (ev.touches.length !== 1) return; ev.preventDefault(); moveTo(ev.touches[0].clientX, ev.touches[0].clientY); }; const end = () => { document.removeEventListener('touchmove', move); document.removeEventListener('touchend', end); document.removeEventListener('touchcancel', end); persist(); }; document.addEventListener('touchmove', move, { passive: false }); document.addEventListener('touchend', end); document.addEventListener('touchcancel', end); }, { passive: false }); } // Resize (mouse + touch) const resize = $('resize'); if (resize) { const startResize = (clientX, clientY) => { const r = panel.getBoundingClientRect(); const iw = r.width, ih = r.height; const sizeTo = (cx, cy) => { panel.style.width = Math.max(280, iw + cx - clientX) + 'px'; panel.style.height = Math.max(300, ih + cy - clientY) + 'px'; }; const persist = () => { const r2 = panel.getBoundingClientRect(); state.panelSize = { width: r2.width, height: r2.height }; try { GM_setValue('panelSize', JSON.stringify(state.panelSize)); } catch (_) {} }; return { sizeTo, persist }; }; resize.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); const { sizeTo, persist } = startResize(e.clientX, e.clientY); const move = (ev) => sizeTo(ev.clientX, ev.clientY); const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); persist(); }; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); }); resize.addEventListener('touchstart', (e) => { if (e.touches.length !== 1) return; const t0 = e.touches[0]; e.preventDefault(); e.stopPropagation(); const { sizeTo, persist } = startResize(t0.clientX, t0.clientY); const move = (ev) => { if (ev.touches.length !== 1) return; ev.preventDefault(); sizeTo(ev.touches[0].clientX, ev.touches[0].clientY); }; const end = () => { document.removeEventListener('touchmove', move); document.removeEventListener('touchend', end); document.removeEventListener('touchcancel', end); persist(); }; document.addEventListener('touchmove', move, { passive: false }); document.addEventListener('touchend', end); document.addEventListener('touchcancel', end); }, { passive: false }); } } /* ========================================================= * 🖼 RENDER * ========================================================= */ function scheduleRender() { if (renderPending) return; renderPending = true; requestAnimationFrame(() => { renderPending = false; try { updateBadge(); updateStatusUI(); if (panel.classList.contains('open')) renderList(); } catch (_) { // UI not fully mounted yet — safe to skip this frame } }); } function updateBadge() { const badge = fab.querySelector('.badge'); if (!badge) return; badge.style.display = state.store.length ? 'inline-block' : 'none'; badge.textContent = state.store.length; } function updateStatusUI() { fab.classList.toggle('paused', state.paused); const pauseBtn = $('pause'); if (pauseBtn) { pauseBtn.textContent = state.paused ? '▶️' : '⏸️'; pauseBtn.classList.toggle('active', state.paused); } const skipBtn = $('skipbody'); if (skipBtn) skipBtn.classList.toggle('active', state.skipBody); const grpBtn = $('group'); if (grpBtn) grpBtn.classList.toggle('active', state.groupByDomain); const autoBtn = $('autoclean'); if (autoBtn) { const on = CONFIG.AUTO_CLEAR_MS > 0; autoBtn.classList.toggle('active', on); autoBtn.textContent = on ? '✓ Auto Clean' : 'Auto Clean'; } } function toast(msg, color) { const t = document.createElement('div'); t.className = 'toast'; if (color) t.style.background = color; t.textContent = msg; shadow.appendChild(t); setTimeout(() => t.remove(), 1600); } const toCurl = (log) => { if (log.type === 'ws') return `# WebSocket: ${log.url}`; let c = `curl '${log.url}' \\\n -X ${log.method}`; Object.entries(log.requestHeaders || {}).forEach(([k, v]) => { c += ` \\\n -H '${k}: ${String(v).replace(/'/g, "'\\''")}'`; }); if (log.requestBody && typeof log.requestBody === 'string' && !log.requestBody.startsWith('[')) { c += ` \\\n --data-raw '${log.requestBody.replace(/'/g, "'\\''")}'`; } return c; }; const toText = (log) => { let s = `=== ${log.method} ${log.url}\n`; s += `Time: ${log.time} | Status: ${log.status || '-'} ${log.statusText || ''} | ${log.duration || 0}ms | ${log.type}`; if (log.sensitive) s += ` | ⚠ SENSITIVE(${log.sensitiveFlags.join(',')})`; s += '\n\n--- Request Headers ---\n'; Object.entries(log.requestHeaders || {}).forEach(([k, v]) => s += `${k}: ${v}\n`); if (log.requestBody) s += `\n--- Request Body ---\n${log.requestBody}\n`; s += `\n--- Response Headers ---\n`; Object.entries(log.responseHeaders || {}).forEach(([k, v]) => s += `${k}: ${v}\n`); if (log.type === 'ws') { s += `\n--- WS Frames (${(log.frames || []).length}) ---\n`; (log.frames || []).forEach(f => s += `[${new Date(f.time).toISOString()}] ${f.dir === 'in' ? '<' : '>'} ${f.data}\n`); } else { s += `\n--- Response Body ---\n${log.responseBody || ''}\n`; } return s; }; const download = (filename, content, type = 'text/plain') => { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 2000); }; const copy = (txt) => { try { GM_setClipboard(txt); toast('Copied!'); return; } catch (_) {} try { navigator.clipboard.writeText(txt).then(() => toast('Copied!')); } catch (_) { toast('Copy failed', '#ef4444'); } }; const esc = (s) => String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>'); const filtered = () => !state.filter ? state.store : state.store.filter(l => l.url.toLowerCase().includes(state.filter)); function renderList() { const listEl = $('list'); if (!listEl) return; const logs = filtered(); if (!logs.length) { let msg; if (!state.hookOK && state.hookError) msg = `⚠ Hook failed: ${state.hookError}`; else if (!state.hookOK) msg = `Installing hook…`; else if (state.paused) msg = '⏸ Paused. Click ▶ to resume.'; else msg = 'Hook active. Waiting for requests…'; listEl.innerHTML = '
' + esc(msg) + '
'; return; } const reversed = logs.slice(-CONFIG.RENDER_CAP).reverse(); let html = ''; if (state.groupByDomain) { const groups = {}; for (const l of reversed) { (groups[l.hostname] = groups[l.hostname] || []).push(l); } for (const host of Object.keys(groups)) { html += `
${esc(host)} · ${groups[host].length}
`; html += groups[host].map(itemHTML).join(''); } } else { html = reversed.map(itemHTML).join(''); } listEl.innerHTML = html; listEl.querySelectorAll('.item').forEach(el => { el.addEventListener('click', () => { state.selectedId = el.dataset.id; renderList(); renderDetail(); }); }); } const itemHTML = (l) => { const sClass = 's-' + String(l.status || 0).charAt(0); const baseline = l.id === state.baselineId ? ' baseline' : ''; const active = l.id === state.selectedId ? ' active' : ''; const flag = l.sensitive ? '🔒' : ''; const observed = l._observed ? '👁' : ''; const wsInfo = l.type === 'ws' ? ` · ${(l.frames || []).length}f` : ''; let displayUrl = l.url; if (state.groupByDomain && l.hostname && l.hostname !== '(local)') { try { const u = new URL(l.url); displayUrl = (u.pathname || '/') + (u.search || '') + (u.hash || ''); } catch (_) {} } return `
${l.method} ${l.status || '—'} ${flag}${observed} ${esc(displayUrl)}${wsInfo}
`; }; function renderDetail() { const detailEl = $('detail'); if (!detailEl) return; const log = state.store.find(l => l.id === state.selectedId); if (!log) { detailEl.innerHTML = '
Select a request
'; return; } const sensitiveTag = log.sensitive ? `⚠ SENSITIVE` : ''; let frameHTML = ''; if (log.type === 'ws') { frameHTML = `

WS Frames (${(log.frames || []).length}${log.closed ? ' · closed ' + log.closeCode : ''})

`; frameHTML += '
' + (log.frames || []).slice(-100).map(f => `
${new Date(f.time).toLocaleTimeString()}${f.dir === 'in' ? '◀' : '▶'} ${esc(f.data)}
` ).join('') + '
'; } const diffHTML = (state.baselineId && state.baselineId !== log.id) ? `` : ''; detailEl.innerHTML = `
${log.method} ${esc(log.url)} ${sensitiveTag}
Status: ${log.status || '-'} ${esc(log.statusText || '')} · ${log.duration || 0}ms · ${log.type} · host: ${esc(log.hostname)} · ${esc(log.time)}
${log.type !== 'ws' ? '' : ''} ${diffHTML}

Request Headers

${esc(JSON.stringify(log.requestHeaders || {}, null, 2))}
${log.requestBody ? `

Request Body

${esc(log.requestBody)}
` : ''}

Response Headers

${esc(JSON.stringify(log.responseHeaders || {}, null, 2))}
${log.type === 'ws' ? frameHTML : `

Response Body

${esc(log.responseBody || '')}
`} `; detailEl.querySelectorAll('[data-act]').forEach(b => { b.addEventListener('click', () => handleDetailAction(b.dataset.act, log)); }); } const handleDetailAction = (act, log) => { switch (act) { case 'copy-json': copy(JSON.stringify(log, null, 2)); break; case 'copy-curl': copy(toCurl(log)); break; case 'copy-text': copy(toText(log)); break; case 'copy-body': copy(log.responseBody || ''); break; case 'replay': toast('Replaying…', '#6366f1'); replay(log).then(r => { toast(`Replay: ${r.status} ${r.statusText}`, r.status < 400 ? '#10b981' : '#ef4444'); }).catch(e => toast('Replay failed: ' + e.message, '#ef4444')); break; case 'baseline': state.baselineId = (state.baselineId === log.id) ? null : log.id; toast(state.baselineId ? 'Baseline set' : 'Baseline cleared'); renderList(); renderDetail(); break; case 'do-diff': { const a = state.store.find(l => l.id === state.baselineId); if (!a) return toast('Baseline not found', '#ef4444'); try { const changes = diff(a, log); const out = $('diff-output'); if (!changes.length) { out.innerHTML = '
✓ No differences
'; return; } const html = changes.slice(0, 100).map(c => { if (c.type === 'added') return `
+ ${esc(c.path)}: ${esc(JSON.stringify(c.to))}
`; if (c.type === 'removed') return `
- ${esc(c.path)}: ${esc(JSON.stringify(c.from))}
`; return `
~ ${esc(c.path)}: ${esc(JSON.stringify(c.from))} → ${esc(JSON.stringify(c.to))}
`; }).join(''); out.innerHTML = `

Diff (${changes.length} change${changes.length !== 1 ? 's' : ''})

${html}
`; } catch (e) { toast('Diff failed: ' + e.message, '#ef4444'); } break; } } }; function updateDiag() { const diagEl = $('diag'); if (!diagEl) return; let autoText = ''; if (CONFIG.AUTO_CLEAR_MS > 0 && state.autoClearNext) { const remaining = Math.max(0, Math.ceil((state.autoClearNext - Date.now()) / 1000)); const color = remaining < 10 ? '#f87171' : '#fcd34d'; autoText = ` · 🧹AUTO sweep in ${remaining}s`; } diagEl.innerHTML = ` TekMonts | ${isFirefox ? '🦊 Firefox' : '🌐 Chrome'} · ${state.hookMethod} · ${state.hookOK ? '✓' : '✗'} · ${state.store.length}/${CONFIG.MAX_LOGS} logs${state.paused ? ' · ⏸️ PAUSED' : ''}${state.skipBody ? ' · NO-BODY' : ''}${autoText} API: __XHR_LOGGER__ `; diagEl.className = state.hookOK ? 'diag' : 'diag err'; } setInterval(updateDiag, 1000); setTimeout(() => { scheduleRender(); updateDiag(); }, 300); console.log( '%c[XHR Logger v2.2 %cShadow DOM isolated. API: %c__XHR_LOGGER__', 'color:#a5b4fc;font-weight:700', 'color:#64748b', 'color:#6ee7b7;font-family:monospace' ); })();