const storagePrefix = 'webhook-inspector:xhr-redirect:'; const forwardedRequestIds = new Set(); const inMemoryConfigurations = new Map(); const forbiddenRequestHeaders = new Set([ 'accept-charset', 'accept-encoding', 'access-control-request-headers', 'access-control-request-method', 'connection', 'content-length', 'cookie', 'cookie2', 'date', 'dnt', 'expect', 'host', 'keep-alive', 'origin', 'referer', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'user-agent', 'via', ]); let livewireListenerRegistered = false; document.addEventListener('DOMContentLoaded', initializeXhrRedirect); document.addEventListener('click', handleDocumentClick); document.addEventListener('input', handleDocumentInput); document.addEventListener('submit', handleDocumentSubmit); document.addEventListener('keydown', handleDocumentKeydown); document.addEventListener('livewire:init', initializeXhrRedirect); function initializeXhrRedirect() { hydrateSettings(); if (livewireListenerRegistered || ! window.Livewire) { return; } livewireListenerRegistered = true; window.Livewire.on('xhr-request-available', handleAvailableRequest); window.Livewire.hook('morphed', hydrateSettings); } function hydrateSettings() { document.querySelectorAll('[data-xhr-redirect-settings]').forEach((settings) => { const endpointId = endpointIdFor(settings); if (! endpointId) { return; } const form = settings.querySelector('[data-xhr-redirect-config-form]'); const targetInput = settings.querySelector('[data-xhr-redirect-target]'); const enabledInput = settings.querySelector('[data-xhr-redirect-enabled]'); if (! form || ! targetInput || ! enabledInput) { return; } const configuration = readConfiguration(endpointId); if (form.dataset.xhrRedirectDirty !== 'true') { targetInput.value = configuration.target; } enabledInput.checked = configuration.enabled; updateSettingsStatus(settings, configuration); }); } function handleDocumentClick(event) { const element = event.target instanceof Element ? event.target : null; if (! element) { return; } const openButton = element.closest('[data-xhr-redirect-open]'); if (openButton) { const modal = openButton.closest('[data-xhr-redirect-endpoint]')?.querySelector('[data-xhr-redirect-modal]'); if (! modal) { return; } modal.xhrRequestPayload = readButtonPayload(openButton); openModal(modal); return; } const closeButton = element.closest('[data-xhr-redirect-close]'); if (closeButton) { closeModal(closeButton.closest('[data-xhr-redirect-modal]')); } } function handleDocumentInput(event) { const element = event.target instanceof HTMLInputElement ? event.target : null; const form = element?.closest('[data-xhr-redirect-config-form]'); if (form) { form.dataset.xhrRedirectDirty = 'true'; } } function handleDocumentSubmit(event) { const form = event.target instanceof HTMLFormElement ? event.target : null; if (! form) { return; } const configurationForm = form.closest('[data-xhr-redirect-config-form]'); if (configurationForm) { event.preventDefault(); saveConfiguration(configurationForm); return; } const sendForm = form.closest('[data-xhr-redirect-send-form]'); if (sendForm) { event.preventDefault(); sendSelectedRequest(sendForm); } } function handleDocumentKeydown(event) { if (event.key !== 'Escape') { return; } const modal = document.querySelector('[data-xhr-redirect-modal]:not([hidden])'); if (modal) { closeModal(modal); } } function saveConfiguration(form) { const settings = form.closest('[data-xhr-redirect-settings]'); const endpointId = endpointIdFor(settings); const targetInput = form.querySelector('[data-xhr-redirect-target]'); const enabledInput = form.querySelector('[data-xhr-redirect-enabled]'); const target = targetInput?.value.trim() ?? ''; const enabled = enabledInput?.checked ?? false; if (! endpointId || ! targetInput || ! enabledInput) { return; } if (enabled && ! isHttpUrl(target)) { const error = new Error('Enter a valid http:// or https:// target URL.'); console.error('[Webhook Inspector] XHR redirect configuration failed.', { endpointId, target, error: error.message, }); updateFeedback(settings, error.message, 'error'); return; } const configuration = { enabled, target }; writeConfiguration(endpointId, configuration); form.dataset.xhrRedirectDirty = 'false'; updateSettingsStatus(settings, configuration); updateFeedback( settings, enabled ? 'XHR redirect is active for new requests in this tab.' : 'XHR redirect disabled.', enabled ? 'success' : 'neutral', ); } async function handleAvailableRequest({ endpointId, requestId }) { if (! endpointId || ! requestId) { return; } const configuration = readConfiguration(endpointId); if (! configuration.enabled || ! isHttpUrl(configuration.target)) { return; } const requestKey = `${endpointId}:${requestId}`; if (forwardedRequestIds.has(requestKey)) { return; } forwardedRequestIds.add(requestKey); const root = findEndpointRoot(endpointId); let wireElement = root; while (wireElement && ! wireElement.hasAttribute('wire:id')) { wireElement = wireElement.parentElement; } const wireId = wireElement?.getAttribute('wire:id'); const component = wireId ? window.Livewire?.find(wireId) : null; if (! component) { logXhrError( { endpointId, requestId }, configuration.target, new Error('The Livewire component is not available to load the request payload.'), 'automatic', ); return; } try { const payload = await component.xhrRequestPayload(requestId); const result = await sendRequest(payload, configuration.target); const settings = root?.querySelector('[data-xhr-redirect-settings]'); logXhrResult(payload, configuration.target, result, 'automatic'); if (settings) { updateFeedback(settings, `Forwarded ${payload.method} request (${result.status}).`, 'success'); } } catch (error) { const settings = root?.querySelector('[data-xhr-redirect-settings]'); logXhrError({ endpointId, requestId }, configuration.target, error, 'automatic'); if (settings) { updateFeedback(settings, error.message || 'XHR redirect failed. Check the target and its CORS response.', 'error'); } } } function sendSelectedRequest(form) { const modal = form.closest('[data-xhr-redirect-modal]'); const targetInput = form.querySelector('[data-xhr-redirect-target]'); const sendButton = form.querySelector('[data-xhr-redirect-send]'); const statusElement = form.querySelector('[data-xhr-redirect-status]'); const target = targetInput?.value.trim() ?? ''; const payload = modal?.xhrRequestPayload; if (! modal || ! targetInput || ! sendButton || ! statusElement || ! payload) { return; } if (! isHttpUrl(target)) { const error = new Error('Enter a valid http:// or https:// target URL.'); logXhrError(payload, target, error, 'manual'); updateStatus(statusElement, error.message, 'error'); targetInput.focus(); return; } sendButton.disabled = true; updateStatus(statusElement, `Sending ${payload.method} request…`, 'neutral'); sendRequest(payload, target) .then((result) => { showResponse(modal, result); logXhrResult(payload, target, result, 'manual'); updateStatus(statusElement, `Request completed with ${result.status} ${result.statusText}.`, result.status >= 200 && result.status < 400 ? 'success' : 'error'); }) .catch((error) => { hideResponse(modal); logXhrError(payload, target, error, 'manual'); updateStatus(statusElement, error.message || 'XHR redirect failed. Check the target and its CORS response.', 'error'); }) .finally(() => { sendButton.disabled = false; }); } function sendRequest(payload, target) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const targetUrl = appendRequestQuery(target, payload.requestUri); try { xhr.open(payload.method, targetUrl, true); xhr.timeout = 30_000; Object.entries(payload.headers ?? {}).forEach(([name, values]) => { if (isForbiddenRequestHeader(name)) { return; } try { xhr.setRequestHeader(name, Array.isArray(values) ? values.join(', ') : String(values)); } catch { // Browsers reject a few additional headers depending on their context. } }); } catch { reject(new Error('The target URL or HTTP method is not supported by the browser.')); return; } xhr.onload = () => resolve({ status: xhr.status, statusText: xhr.statusText || 'Unknown status', headers: xhr.getAllResponseHeaders().trim(), body: xhr.responseText, }); xhr.onerror = () => reject(new Error('The browser could not reach the target. Check its CORS headers and whether it is running.')); xhr.ontimeout = () => reject(new Error('The target did not respond within 30 seconds.')); xhr.onabort = () => reject(new Error('The XHR request was aborted.')); try { const body = decodeBase64ToBytes(payload.body ?? ''); const method = payload.method.toUpperCase(); xhr.send(['GET', 'HEAD'].includes(method) || body.byteLength === 0 ? null : body); } catch { reject(new Error('The captured request body could not be decoded.')); } }); } function logXhrResult(payload, target, result, mode) { const details = { mode, endpointId: payload?.endpointId ?? null, requestId: payload?.requestId ?? null, method: payload?.method ?? null, target: appendRequestQuery(target, payload?.requestUri), status: result.status, statusText: result.statusText, }; if (result.status >= 400 || result.status === 0) { console.error('[Webhook Inspector] XHR forwarding failed.', details); return; } console.info('[Webhook Inspector] XHR forwarding succeeded.', details); } function logXhrError(payload, target, error, mode) { console.error('[Webhook Inspector] XHR forwarding failed.', { mode, endpointId: payload?.endpointId ?? null, requestId: payload?.requestId ?? null, method: payload?.method ?? null, target: appendRequestQuery(target, payload?.requestUri), error: error instanceof Error ? error.message : String(error), }); } function readButtonPayload(button) { try { return { endpointId: button.dataset.xhrRequestEndpoint, requestId: button.dataset.xhrRequestId, method: button.dataset.xhrRequestMethod, requestUri: button.dataset.xhrRequestUri, headers: JSON.parse(decodeBase64ToString(button.dataset.xhrRequestHeaders ?? 'e30=')), body: button.dataset.xhrRequestBody ?? '', }; } catch { return null; } } function appendRequestQuery(target, requestUri) { const queryStart = String(requestUri ?? '').indexOf('?'); if (queryStart === -1) { return target; } const query = String(requestUri).slice(queryStart + 1); if (query === '') { return target; } const hashStart = target.indexOf('#'); const hash = hashStart === -1 ? '' : target.slice(hashStart); const targetWithoutHash = hashStart === -1 ? target : target.slice(0, hashStart); const separator = targetWithoutHash.includes('?') ? (targetWithoutHash.endsWith('?') || targetWithoutHash.endsWith('&') ? '' : '&') : '?'; return `${targetWithoutHash}${separator}${query}${hash}`; } function isForbiddenRequestHeader(name) { const normalizedName = name.toLowerCase(); return forbiddenRequestHeaders.has(normalizedName) || normalizedName.startsWith('proxy-') || normalizedName.startsWith('sec-'); } function isHttpUrl(value) { try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol); } catch { return false; } } function readConfiguration(endpointId) { const fallback = inMemoryConfigurations.get(endpointId); try { const stored = window.sessionStorage.getItem(storagePrefix + endpointId); if (stored) { const configuration = JSON.parse(stored); if (typeof configuration.enabled === 'boolean' && typeof configuration.target === 'string') { return configuration; } } } catch { // Session storage may be unavailable in privacy-restricted browsers. } return fallback ?? { enabled: false, target: '' }; } function writeConfiguration(endpointId, configuration) { inMemoryConfigurations.set(endpointId, configuration); try { window.sessionStorage.setItem(storagePrefix + endpointId, JSON.stringify(configuration)); } catch { // Keep the in-memory configuration for the current page when storage is unavailable. } } function updateSettingsStatus(settings, configuration) { const activeBadge = settings?.querySelector('[data-xhr-redirect-active]'); if (activeBadge) { activeBadge.hidden = ! configuration.enabled; } } function updateFeedback(settings, message, tone) { const feedback = settings?.querySelector('[data-xhr-redirect-feedback]'); if (! feedback) { return; } feedback.textContent = message; feedback.classList.remove('text-slate-500', 'text-emerald-300', 'text-rose-300'); feedback.classList.add({ success: 'text-emerald-300', error: 'text-rose-300', neutral: 'text-slate-500' }[tone] ?? 'text-slate-500'); } function updateStatus(statusElement, message, tone) { statusElement.textContent = message; statusElement.classList.remove('text-slate-400', 'text-cyan-200', 'text-emerald-200', 'text-rose-200'); statusElement.classList.add({ success: 'text-emerald-200', error: 'text-rose-200', neutral: 'text-slate-400' }[tone] ?? 'text-slate-400'); } function showResponse(modal, result) { const response = modal.querySelector('[data-xhr-redirect-response]'); const status = modal.querySelector('[data-xhr-response-status]'); const headers = modal.querySelector('[data-xhr-response-headers]'); const body = modal.querySelector('[data-xhr-response-body]'); if (! response || ! status || ! headers || ! body) { return; } response.hidden = false; status.textContent = `${result.status} ${result.statusText}`; headers.textContent = limitText(result.headers || '(No response headers exposed by CORS.)', 8_000); body.textContent = limitText(result.body || '(Empty response body.)', 32_000); } function hideResponse(modal) { const response = modal.querySelector('[data-xhr-redirect-response]'); if (response) { response.hidden = true; } } function openModal(modal) { const targetInput = modal.querySelector('[data-xhr-redirect-target]'); const endpointId = endpointIdFor(modal); const configuration = endpointId ? readConfiguration(endpointId) : { enabled: false, target: '' }; if (targetInput && ! targetInput.value) { targetInput.value = configuration.target; } modal.hidden = false; hideResponse(modal); updateStatus(modal.querySelector('[data-xhr-redirect-status]'), 'Ready to send.', 'neutral'); targetInput?.focus(); } function closeModal(modal) { if (modal) { modal.hidden = true; } } function findEndpointRoot(endpointId) { return Array.from(document.querySelectorAll('[data-xhr-redirect-endpoint]')) .find((root) => root.dataset.xhrRedirectEndpoint === endpointId) ?? null; } function endpointIdFor(element) { return element?.closest('[data-xhr-redirect-endpoint]')?.dataset.xhrRedirectEndpoint ?? null; } function decodeBase64ToBytes(value) { const binary = atob(value); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index++) { bytes[index] = binary.charCodeAt(index); } return bytes; } function decodeBase64ToString(value) { return new TextDecoder().decode(decodeBase64ToBytes(value)); } function limitText(value, limit) { return value.length > limit ? `${value.slice(0, limit)}\n\n[Response truncated]` : value; }