// ==UserScript== // @name Mendix Traffic Interceptor // @namespace http://tampermonkey.net/ // @version 2.0.0 // @description Intercept, pause, edit and replay Mendix XHR/fetch traffic — with full request/response logging // @match *://*/* // @grant none // @run-at document-start // ==/UserScript== (function () { 'use strict'; const state = { intercept: false, requests: [], pending: new Map(), nextId: 1, panel: null, visible: true, expanded: new Set(), // ids with open detail view activeTab: 'log', }; const isMendix = (url) => /\/xas\/|rest-doc|\/link\/|\/p\/|runtime\//i.test(url) || url.includes('mendix'); function genId() { return state.nextId++; } function tryJson(str) { try { return JSON.parse(str); } catch { return null; } } function shortUrl(url) { try { return new URL(url).pathname.slice(0, 55); } catch { return String(url).slice(0, 55); } } function escHtml(s) { return String(s) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function formatBody(str) { const j = tryJson(str); return j ? JSON.stringify(j, null, 2) : String(str); } // ── Blokkeer navigatie tijdens intercept ───────────────────────────────── function installNavBlocker() { // Blokkeer history navigatie const origPush = history.pushState.bind(history); const origReplace = history.replaceState.bind(history); history.pushState = function (...args) { if (state.intercept) { notify('Navigatie geblokkeerd: ' + args[2]); return; } origPush(...args); }; history.replaceState = function (...args) { if (state.intercept) { notify('Navigatie geblokkeerd: ' + args[2]); return; } origReplace(...args); }; // Blokkeer window.location wijzigingen const locDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); if (!locDescriptor || locDescriptor.configurable) { try { const _loc = window.location; Object.defineProperty(window, 'location', { get: () => _loc, set: (v) => { if (state.intercept) { notify('Navigatie geblokkeerd: ' + v); return; } window.location.href = v; }, configurable: true, }); } catch (e) {} } // Blokkeer Mendix eigen router zodra mx beschikbaar is const waitMx = setInterval(() => { if (typeof mx === 'undefined' || !mx.ui) return; clearInterval(waitMx); const origGo = mx.ui.go?.bind(mx.ui); if (origGo) { mx.ui.go = function (...args) { if (state.intercept) { notify('mx.ui.go geblokkeerd: ' + args[0]); return; } origGo(...args); }; } }, 200); } installNavBlocker(); // ── Patch fetch ────────────────────────────────────────────────────────── const _fetch = window.fetch.bind(window); window.fetch = async function (input, init = {}) { const url = typeof input === 'string' ? input : input.url; if (!isMendix(url)) return _fetch(input, init); const id = genId(); const method = (init.method || 'GET').toUpperCase(); const headers = init.headers || {}; const body = init.body || null; const meta = { type: 'fetch', url, method, headers, body }; if (state.intercept) { return new Promise((resolve, reject) => { addPending(id, meta, resolve, reject); }); } // Passive logging try { const resp = await _fetch(input, init); const clone = resp.clone(); const text = await clone.text(); logRequest({ ...meta, id, status: resp.status, response: text, ts: Date.now() }); return resp; } catch (e) { logRequest({ ...meta, id, status: 'ERR', response: String(e), ts: Date.now() }); throw e; } }; // ── Patch XHR ──────────────────────────────────────────────────────────── const _XHR = window.XMLHttpRequest; function PatchedXHR() { const xhr = new _XHR(); let _method, _url, _headers = {}, _body; xhr.open = function (method, url, ...rest) { _method = method.toUpperCase(); _url = url; _XHR.prototype.open.call(xhr, method, url, ...rest); }; xhr.setRequestHeader = function (k, v) { _headers[k] = v; _XHR.prototype.setRequestHeader.call(xhr, k, v); }; xhr.send = function (body) { _body = body; if (!isMendix(_url)) { _XHR.prototype.send.call(xhr, body); return; } const id = genId(); const meta = { type: 'xhr', url: _url, method: _method, headers: _headers, body, xhrRef: xhr }; if (state.intercept) { addPending(id, meta, null, null); return; } // Passive logging — wrap load event const origOnLoad = xhr.onload; xhr.addEventListener('load', () => { logRequest({ ...meta, id, status: xhr.status, response: xhr.responseText, ts: Date.now() }); }); _XHR.prototype.send.call(xhr, body); }; return xhr; } PatchedXHR.prototype = _XHR.prototype; window.XMLHttpRequest = PatchedXHR; // ── Pending management ─────────────────────────────────────────────────── function updateBackdrop() { if (!state.backdrop) return; state.backdrop.style.display = (state.intercept && state.pending.size > 0) ? 'block' : 'none'; } function addPending(id, meta, resolve, reject) { state.pending.set(id, { meta, resolve, reject, ts: Date.now() }); state.activeTab = 'pending'; updateBackdrop(); renderPanel(); notify(`Gepauzeerd: ${meta.method} ${shortUrl(meta.url)}`); } async function forwardRequest(id, editedMeta) { const entry = state.pending.get(id); if (!entry) return; const { meta, resolve, reject } = entry; const m = editedMeta || meta; state.pending.delete(id); updateBackdrop(); if (meta.type === 'fetch') { try { const resp = await _fetch(m.url, { method: m.method, headers: m.headers, body: m.body || undefined }); const clone = resp.clone(); const text = await clone.text(); logRequest({ ...m, id, status: resp.status, response: text, ts: Date.now() }); resolve(resp); } catch (e) { reject(e); } } else { const newXhr = new _XHR(); newXhr.open(m.method, m.url, true); Object.entries(m.headers || {}).forEach(([k, v]) => newXhr.setRequestHeader(k, v)); newXhr.onload = () => { logRequest({ ...m, id, status: newXhr.status, response: newXhr.responseText, ts: Date.now() }); copyXhrResult(meta.xhrRef, newXhr); }; newXhr.send(m.body || null); } renderPanel(); } function copyXhrResult(target, source) { Object.defineProperty(target, 'status', { get: () => source.status, configurable: true }); Object.defineProperty(target, 'responseText', { get: () => source.responseText, configurable: true }); Object.defineProperty(target, 'response', { get: () => source.response, configurable: true }); Object.defineProperty(target, 'readyState', { get: () => 4, configurable: true }); ['load', 'readystatechange'].forEach(evName => { const ev = new Event(evName); target.dispatchEvent(ev); const cb = target['on' + evName]; if (typeof cb === 'function') cb(ev); }); } function dropRequest(id) { const entry = state.pending.get(id); if (!entry) return; if (entry.reject) entry.reject(new Error('Dropped by Mendix Interceptor')); state.pending.delete(id); updateBackdrop(); renderPanel(); } function logRequest(entry) { state.requests.unshift(entry); if (state.requests.length > 300) state.requests.pop(); renderPanel(); } // ── CSS ────────────────────────────────────────────────────────────────── const CSS = ` #mxi-root * { box-sizing: border-box; font-family: 'Consolas', monospace; margin: 0; padding: 0; } #mxi-panel { position: fixed; top: 16px; right: 16px; width: 620px; max-height: 85vh; background: #1e1e2e; color: #cdd6f4; border: 1px solid #45475a; border-radius: 10px; z-index: 2147483647; display: flex; flex-direction: column; box-shadow: 0 12px 40px rgba(0,0,0,.7); overflow: hidden; resize: both; } #mxi-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: #181825; border-bottom: 1px solid #313244; cursor: move; flex-shrink: 0; } #mxi-title { flex: 1; font-size: 13px; font-weight: bold; color: #cba6f7; user-select: none; } .mxi-btn { background: #313244; border: 1px solid #45475a; color: #cdd6f4; padding: 3px 10px; border-radius: 4px; cursor: pointer; font-size: 11px; font-family: monospace; } .mxi-btn:hover { background: #45475a; } .mxi-btn.on { background: #a6e3a1; color: #1e1e2e; border-color: #a6e3a1; font-weight: bold; } .mxi-btn.red { background: #f38ba8; color: #1e1e2e; border-color: #f38ba8; } #mxi-tabs { display: flex; background: #181825; border-bottom: 1px solid #313244; flex-shrink: 0; } .mxi-tab { padding: 7px 16px; font-size: 11px; cursor: pointer; color: #6c7086; border-bottom: 2px solid transparent; user-select: none; } .mxi-tab.active { color: #cba6f7; border-bottom-color: #cba6f7; } #mxi-body { overflow-y: auto; flex: 1; padding: 8px; } /* Request card */ .mxi-card { background: #252536; border-radius: 6px; margin-bottom: 6px; border-left: 3px solid #45475a; overflow: hidden; } .mxi-card.pending { border-left-color: #f9e2af; } .mxi-card.ok { border-left-color: #a6e3a1; } .mxi-card.err { border-left-color: #f38ba8; } .mxi-card-header { display: flex; align-items: center; gap: 8px; padding: 7px 10px; cursor: pointer; user-select: none; } .mxi-card-header:hover { background: #313244; } .mxi-method { font-weight: bold; color: #89b4fa; min-width: 40px; font-size: 11px; } .mxi-url { color: #cdd6f4; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; } .mxi-status-badge { font-size: 10px; padding: 1px 7px; border-radius: 10px; font-weight: bold; background: #313244; color: #a6e3a1; } .mxi-status-badge.err { color: #f38ba8; } .mxi-status-badge.pending { color: #f9e2af; } .mxi-ts { font-size: 9px; color: #585b70; white-space: nowrap; } .mxi-chevron { font-size: 10px; color: #6c7086; transition: transform .15s; } .mxi-chevron.open { transform: rotate(90deg); } /* Detail pane */ .mxi-detail { border-top: 1px solid #313244; padding: 8px; display: none; } .mxi-detail.open { display: block; } .mxi-section-label { font-size: 9px; text-transform: uppercase; color: #6c7086; letter-spacing: 1px; margin: 8px 0 4px; } .mxi-section-label:first-child { margin-top: 0; } textarea.mxi-ta { width: 100%; background: #1e1e2e; color: #cdd6f4; border: 1px solid #45475a; border-radius: 4px; padding: 7px; font-size: 10px; font-family: 'Consolas', monospace; resize: vertical; min-height: 70px; line-height: 1.5; } textarea.mxi-ta.response { min-height: 90px; border-color: #313244; } .mxi-actions { display: flex; gap: 5px; margin-top: 8px; flex-wrap: wrap; } /* Toolbar */ #mxi-toolbar { display: flex; gap: 6px; padding: 6px 8px; background: #181825; border-bottom: 1px solid #313244; align-items: center; flex-shrink: 0; } #mxi-filter { flex: 1; background: #252536; border: 1px solid #45475a; color: #cdd6f4; border-radius: 4px; padding: 3px 8px; font-size: 11px; font-family: monospace; } #mxi-filter::placeholder { color: #585b70; } #mxi-count { font-size: 10px; color: #6c7086; white-space: nowrap; } #mxi-toast { position: fixed; bottom: 20px; right: 20px; background: #313244; color: #cdd6f4; padding: 8px 14px; border-radius: 6px; font-size: 12px; font-family: monospace; z-index: 2147483647; opacity: 0; transition: opacity .3s; border-left: 3px solid #cba6f7; pointer-events: none; } .mxi-badge { background: #f9e2af; color: #1e1e2e; border-radius: 10px; padding: 1px 6px; font-size: 10px; font-weight: bold; } `; // ── Render ─────────────────────────────────────────────────────────────── function renderPanel() { if (!state.panel) return; const p = state.panel; const pendingArr = [...state.pending.entries()]; const filter = (p.querySelector('#mxi-filter') || {}).value || ''; p.innerHTML = `
Mendix Inspector ${pendingArr.length ? `${pendingArr.length}` : ''}
Log (${state.requests.length})
Pending ${pendingArr.length ? `${pendingArr.length}` : ''}
${state.requests.length} requests
${state.activeTab === 'pending' ? renderPending(pendingArr) : renderLog(filter)}
`; // Events p.querySelector('#mxi-toggle').onclick = () => { state.intercept = !state.intercept; renderPanel(); }; p.querySelector('#mxi-clear').onclick = () => { state.requests = []; state.expanded.clear(); renderPanel(); }; p.querySelector('#mxi-close').onclick = () => { p.style.display = 'none'; state.visible = false; }; p.querySelectorAll('.mxi-tab').forEach(t => { t.onclick = () => { state.activeTab = t.dataset.tab; renderPanel(); }; }); p.querySelector('#mxi-filter').oninput = function () { renderBodyOnly(p, this.value); }; // Card toggle p.querySelectorAll('.mxi-card-header[data-id]').forEach(h => { h.onclick = (e) => { if (e.target.closest('.mxi-btn')) return; const id = Number(h.dataset.id); state.expanded.has(id) ? state.expanded.delete(id) : state.expanded.add(id); const detail = h.nextElementSibling; const chev = h.querySelector('.mxi-chevron'); detail.classList.toggle('open', state.expanded.has(id)); if (chev) chev.classList.toggle('open', state.expanded.has(id)); }; }); // Pending buttons pendingArr.forEach(([id]) => { bindPendingButtons(p, id); }); } function renderBodyOnly(p, filter) { const body = p.querySelector('#mxi-body'); if (!body || state.activeTab !== 'log') return; body.innerHTML = renderLog(filter); p.querySelectorAll('.mxi-card-header[data-id]').forEach(h => { h.onclick = (e) => { if (e.target.closest('.mxi-btn')) return; const id = Number(h.dataset.id); state.expanded.has(id) ? state.expanded.delete(id) : state.expanded.add(id); const detail = h.nextElementSibling; const chev = h.querySelector('.mxi-chevron'); detail.classList.toggle('open', state.expanded.has(id)); if (chev) chev.classList.toggle('open', state.expanded.has(id)); }; }); } function renderLog(filter = '') { let items = state.requests; if (filter) { const f = filter.toLowerCase(); items = items.filter(r => r.url.toLowerCase().includes(f) || (r.body && String(r.body).toLowerCase().includes(f)) || (r.response && r.response.toLowerCase().includes(f)) ); } if (!items.length) return '
Geen requests gelogd
'; return items.map(r => { const ok = typeof r.status === 'number' && r.status >= 200 && r.status < 300; const statusClass = r.status === 'ERR' ? 'err' : ok ? '' : 'err'; const cardClass = r.status === 'ERR' ? 'err' : ok ? 'ok' : 'err'; const isOpen = state.expanded.has(r.id); const ts = r.ts ? new Date(r.ts).toLocaleTimeString('nl-NL') : ''; const reqBody = r.body ? formatBody(r.body) : ''; const respBody = r.response ? formatBody(r.response) : ''; // Extract Mendix action from body if JSON const parsed = tryJson(r.body); const action = parsed?.action || ''; return `
${escHtml(r.method)} ${escHtml(shortUrl(r.url))}${action ? ` [${escHtml(action)}]` : ''} ${r.status} ${ts}
${reqBody ? ` ` : ''}
`; }).join(''); } function renderPending(arr) { if (!arr.length) return '
Geen gepauzeerde requests
'; return arr.map(([id, { meta, ts }]) => { const isOpen = state.expanded.has(id); const editObj = JSON.stringify({ url: meta.url, method: meta.method, headers: meta.headers, body: meta.body }, null, 2); const time = ts ? new Date(ts).toLocaleTimeString('nl-NL') : ''; return `
${escHtml(meta.method)} ${escHtml(shortUrl(meta.url))} gepauzeerd ${time}
`; }).join(''); } function bindPendingButtons(p, id) { const fwd = p.querySelector(`#mxi-fwd-${id}`); const drop = p.querySelector(`#mxi-drop-${id}`); if (fwd) fwd.onclick = (e) => { e.preventDefault(); e.stopPropagation(); const entry = state.pending.get(id); if (!entry) return; const editArea = p.querySelector(`#mxi-edit-${id}`); let meta = entry.meta; if (editArea) { try { const edited = JSON.parse(editArea.value); meta = { ...meta, ...edited }; } catch { alert('Ongeldige JSON'); return; } } forwardRequest(id, meta); }; if (drop) drop.onclick = () => dropRequest(id); } // ── Panel init ─────────────────────────────────────────────────────────── function createPanel() { const styleEl = document.createElement('style'); styleEl.textContent = CSS; document.head.appendChild(styleEl); const host = document.createElement('div'); host.id = 'mxi-root'; document.documentElement.appendChild(host); // Backdrop blokkeert alle klikken op de pagina bij pending requests const backdrop = document.createElement('div'); backdrop.id = 'mxi-backdrop'; backdrop.style.cssText = ` position: fixed; inset: 0; z-index: 2147483646; background: rgba(0,0,0,0.4); display: none; cursor: not-allowed; `; backdrop.onclick = (e) => { e.preventDefault(); e.stopPropagation(); }; host.appendChild(backdrop); state.backdrop = backdrop; const panel = document.createElement('div'); panel.id = 'mxi-panel'; host.appendChild(panel); state.panel = panel; const toast = document.createElement('div'); toast.id = 'mxi-toast'; host.appendChild(toast); state.toast = toast; makeDraggable(panel); } function makeDraggable(el) { let ox, oy, drag = false; el.addEventListener('mousedown', (e) => { if (!e.target.closest('#mxi-header') || e.target.closest('.mxi-btn')) return; drag = true; ox = e.clientX - el.getBoundingClientRect().left; oy = e.clientY - el.getBoundingClientRect().top; }); document.addEventListener('mousemove', (e) => { if (!drag) return; el.style.left = (e.clientX - ox) + 'px'; el.style.top = (e.clientY - oy) + 'px'; el.style.right = 'auto'; }); document.addEventListener('mouseup', () => { drag = false; }); } function notify(msg) { if (!state.toast) return; state.toast.textContent = msg; state.toast.style.opacity = '1'; clearTimeout(state._tt); state._tt = setTimeout(() => { state.toast.style.opacity = '0'; }, 2500); } document.addEventListener('keydown', (e) => { if (e.altKey && e.key === 'm') { if (!state.panel) { createPanel(); renderPanel(); } state.visible = !state.visible; state.panel.style.display = state.visible ? 'flex' : 'none'; } }); function init() { if (state.panel) return; createPanel(); state.panel.style.display = 'flex'; renderPanel(); } if (document.readyState === 'loading') { window.addEventListener('DOMContentLoaded', init); } else { init(); } })();