// ==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 = `