Created
March 4, 2025 19:32
-
-
Save petercossey/4519ebce4265a18d3224a6df318d76a1 to your computer and use it in GitHub Desktop.
XHR interceptor to add your own business logic at key points in the request lifecycle
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script> | |
(function() { | |
// Store the original XMLHttpRequest | |
const OriginalXHR = window.XMLHttpRequest; | |
// Store the original fetch (we'll use this for our side requests) | |
const originalFetch = window.fetch; | |
// Create a unique ID for this interceptor | |
const interceptorId = 'xhr-' + Math.random().toString(36).substr(2, 9); | |
// Flag to prevent recursive interception | |
const pendingSideRequests = new Set(); | |
// Track request count for unique IDs | |
let requestCounter = 0; | |
// Storage for active requests | |
const activeRequests = {}; | |
// ====== SIDE REQUEST FUNCTIONS ====== | |
// Make a non-blocking side request (won't delay the original request) | |
const makeAsyncSideRequest = function(url, options = {}) { | |
// Add a marker to identify this as a side request | |
const requestId = `side-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; | |
pendingSideRequests.add(requestId); | |
console.log(`%cMaking async side request: ${options.method || 'GET'} ${url}`, | |
'color: #2196F3; font-weight: bold;'); | |
// Use the original fetch to avoid recursion | |
return originalFetch(url, options) | |
.then(response => response.json()) | |
.then(data => { | |
console.log(`%cAsync side request completed: ${url}`, | |
'color: #4CAF50; font-weight: bold;', data); | |
pendingSideRequests.delete(requestId); | |
return data; | |
}) | |
.catch(error => { | |
console.error(`%cAsync side request failed: ${url}`, | |
'color: #F44336; font-weight: bold;', error); | |
pendingSideRequests.delete(requestId); | |
throw error; | |
}); | |
}; | |
// Make a blocking side request (will delay the original request until completed) | |
const makeSyncSideRequest = async function(url, options = {}) { | |
// Add a marker to identify this as a side request | |
const requestId = `side-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; | |
pendingSideRequests.add(requestId); | |
console.log(`%cMaking blocking side request: ${options.method || 'GET'} ${url}`, | |
'color: #FF9800; font-weight: bold;'); | |
try { | |
// Use the original fetch to avoid recursion | |
const response = await originalFetch(url, options); | |
const data = await response.json(); | |
console.log(`%cBlocking side request completed: ${url}`, | |
'color: #4CAF50; font-weight: bold;', data); | |
pendingSideRequests.delete(requestId); | |
return data; | |
} catch (error) { | |
console.error(`%cBlocking side request failed: ${url}`, | |
'color: #F44336; font-weight: bold;', error); | |
pendingSideRequests.delete(requestId); | |
throw error; | |
} | |
}; | |
// Alternative: Make a side request using a new XMLHttpRequest | |
const makeXHRSideRequest = function(url, options = {}) { | |
return new Promise((resolve, reject) => { | |
// Create a genuine XHR request (not intercepted) | |
const sideXhr = new OriginalXHR(); | |
console.log(`%cMaking XHR side request: ${options.method || 'GET'} ${url}`, | |
'color: #9C27B0; font-weight: bold;'); | |
sideXhr.open(options.method || 'GET', url); | |
// Add headers | |
if (options.headers) { | |
Object.entries(options.headers).forEach(([key, value]) => { | |
sideXhr.setRequestHeader(key, value); | |
}); | |
} | |
sideXhr.onload = function() { | |
if (this.status >= 200 && this.status < 300) { | |
let result; | |
try { | |
result = JSON.parse(this.responseText); | |
} catch (e) { | |
result = this.responseText; | |
} | |
console.log(`%cXHR side request completed: ${url}`, | |
'color: #4CAF50; font-weight: bold;', result); | |
resolve(result); | |
} else { | |
console.error(`%cXHR side request failed: ${url}`, | |
'color: #F44336; font-weight: bold;', this.status, this.statusText); | |
reject(new Error(`${this.status} ${this.statusText}`)); | |
} | |
}; | |
sideXhr.onerror = function() { | |
console.error(`%cXHR side request error: ${url}`, | |
'color: #F44336; font-weight: bold;'); | |
reject(new Error('Network error')); | |
}; | |
sideXhr.send(options.body); | |
}); | |
}; | |
// ====== BUSINESS LOGIC HOOKS ====== | |
// Hook: Before request is opened | |
// Return modified arguments to change method or URL | |
const beforeOpen = function(method, url, async, user, password) { | |
// You can make a side request here if you need | |
// to determine something before the request is opened | |
if (url.includes('/api/product/')) { | |
// Example: Non-blocking side request to get product metadata | |
// This won't delay the original request | |
makeAsyncSideRequest('/api/product-metadata', { | |
method: 'GET', | |
headers: { 'Content-Type': 'application/json' } | |
}).then(metadata => { | |
console.log('Product metadata received:', metadata); | |
// You could store this for later use | |
window._productMetadata = metadata; | |
}); | |
} | |
return [method, url, async, user, password]; | |
}; | |
// Hook: Before request headers are set | |
const beforeSetRequestHeader = function(header, value) { | |
// Example: Add a custom header based on a side request | |
// This is just an example of what you could do, | |
// but this specific implementation isn't practical | |
// since this would be called for every header | |
return [header, value]; | |
}; | |
// Hook: Before request is sent (with async support) | |
// Important: This will be awaited if it returns a Promise | |
const beforeSend = async function(body, requestData) { | |
// Example: Conditionally make a blocking side request | |
// This WILL delay the original request until completed | |
if (requestData.url.includes('/api/checkout') && | |
typeof body === 'string' && | |
body.includes('"productId"')) { | |
try { | |
// Parse the request body to get the product ID | |
const data = JSON.parse(body); | |
const productId = data.productId; | |
// Make a blocking request to validate product availability | |
const availabilityData = await makeSyncSideRequest(`/api/inventory/${productId}`, { | |
method: 'GET', | |
headers: { 'Content-Type': 'application/json' } | |
}); | |
// Modify the request body based on availability | |
if (availabilityData && availabilityData.inStock) { | |
data.availabilityChecked = true; | |
data.availabilityTimestamp = new Date().toISOString(); | |
return JSON.stringify(data); | |
} else { | |
// You could even cancel or modify the request based on results | |
console.warn('Product not available, modifying checkout request'); | |
data.availabilityError = true; | |
return JSON.stringify(data); | |
} | |
} catch (error) { | |
console.error('Error checking product availability:', error); | |
// Continue with original request if the side request fails | |
} | |
} | |
return body; | |
}; | |
// Hook: After response is received | |
const afterResponse = function(xhr, requestData) { | |
// Example: Send telemetry data about responses | |
if (xhr.status >= 400) { | |
// Send error telemetry (non-blocking) | |
makeAsyncSideRequest('/api/telemetry/error', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ | |
url: requestData.url, | |
method: requestData.method, | |
status: xhr.status, | |
timestamp: new Date().toISOString(), | |
requestId: requestData.id | |
}) | |
}).catch(err => { | |
// Silently handle telemetry failures | |
console.error('Failed to send error telemetry:', err); | |
}); | |
} | |
return xhr; | |
}; | |
// ====== IMPLEMENTATION (MINIMAL CHANGES BELOW THIS POINT) ====== | |
// Helper to create log groups | |
const createLogGroup = (method, url, requestId) => { | |
const styles = 'color: #fff; padding: 2px 5px; border-radius: 3px;'; | |
console.group( | |
`%c${method}%c ${url} %c#${requestId}`, | |
`${styles} background: #9C27B0;`, | |
'color: inherit;', | |
'color: #6c757d; font-size: 90%;' | |
); | |
}; | |
// Format response body | |
const formatResponseBody = (xhr, requestId) => { | |
const contentType = xhr.getResponseHeader('content-type'); | |
if (!contentType || xhr.responseText === '') { | |
console.log('Response Body: [Empty]'); | |
return; | |
} | |
if (contentType.includes('application/json')) { | |
try { | |
const json = JSON.parse(xhr.responseText); | |
console.log('Response Body (JSON):', json); | |
} catch (e) { | |
console.log('Response Body (Text - Invalid JSON):', xhr.responseText); | |
} | |
} else if (contentType.includes('text/')) { | |
const text = xhr.responseText; | |
console.log('Response Body (Text):', text.substring(0, 1000) + (text.length > 1000 ? '...' : '')); | |
} else if (contentType.includes('xml')) { | |
console.log('Response Body (XML):', xhr.responseXML || xhr.responseText); | |
} else { | |
console.log('Response Body: [Binary data not displayed]'); | |
} | |
}; | |
// Create custom XMLHttpRequest implementation | |
window.XMLHttpRequest = function() { | |
const xhr = new OriginalXHR(); | |
const requestId = ++requestCounter; | |
const requestData = { | |
id: requestId, | |
method: null, | |
url: null, | |
startTime: null, | |
headers: {}, | |
body: null | |
}; | |
activeRequests[requestId] = requestData; | |
// Override open method | |
const originalOpen = xhr.open; | |
xhr.open = function(method, url, async = true, user, password) { | |
const modifiedArgs = beforeOpen(method, url, async, user, password) || arguments; | |
requestData.method = modifiedArgs[0] || method; | |
requestData.url = modifiedArgs[1] || url; | |
createLogGroup(requestData.method, requestData.url, requestId); | |
console.log('Request Time:', new Date().toISOString()); | |
console.log('URL:', requestData.url); | |
console.log('Method:', requestData.method); | |
return originalOpen.apply(xhr, modifiedArgs); | |
}; | |
// Override setRequestHeader method | |
const originalSetRequestHeader = xhr.setRequestHeader; | |
xhr.setRequestHeader = function(header, value) { | |
const modifiedArgs = beforeSetRequestHeader(header, value); | |
if (modifiedArgs === false) { | |
console.log(`Header blocked: ${header}`); | |
return; | |
} | |
const headerToUse = modifiedArgs?.[0] || header; | |
const valueToUse = modifiedArgs?.[1] || value; | |
requestData.headers[headerToUse] = valueToUse; | |
return originalSetRequestHeader.call(xhr, headerToUse, valueToUse); | |
}; | |
// Override send method | |
const originalSend = xhr.send; | |
xhr.send = function(body) { | |
requestData.startTime = performance.now(); | |
requestData.body = body; | |
// Log request headers | |
console.log('Request Headers:', requestData.headers); | |
// Log request body | |
if (body) { | |
if (body instanceof FormData) { | |
console.log('Request Body: [FormData]'); | |
} else if (body instanceof Blob) { | |
console.log('Request Body: [Blob]'); | |
} else if (body instanceof ArrayBuffer) { | |
console.log('Request Body: [ArrayBuffer]'); | |
} else if (typeof body === 'string') { | |
try { | |
const json = JSON.parse(body); | |
console.log('Request Body (JSON):', json); | |
} catch (e) { | |
console.log('Request Body (Text):', body); | |
} | |
} else if (body) { | |
console.log('Request Body:', body); | |
} | |
} | |
// Helper function to actually send the request | |
const sendRequest = async (bodyToSend) => { | |
// Add handlers for response | |
xhr.addEventListener('readystatechange', function() { | |
if (xhr.readyState === 4) { | |
const endTime = performance.now(); | |
const duration = endTime - requestData.startTime; | |
console.log('Status:', xhr.status, xhr.statusText); | |
console.log('Response Time:', duration.toFixed(2) + 'ms'); | |
// Get response headers | |
const responseHeaders = {}; | |
const headerString = xhr.getAllResponseHeaders(); | |
if (headerString) { | |
const headerLines = headerString.split('\r\n'); | |
for (let i = 0; i < headerLines.length; i++) { | |
const line = headerLines[i]; | |
if (line) { | |
const parts = line.split(': '); | |
const header = parts.shift(); | |
const value = parts.join(': '); | |
responseHeaders[header] = value; | |
} | |
} | |
} | |
console.log('Response Headers:', responseHeaders); | |
// Format and log response body | |
formatResponseBody(xhr, requestId); | |
// Apply business logic to response | |
afterResponse(xhr, requestData); | |
console.groupEnd(); | |
// Clean up | |
delete activeRequests[requestId]; | |
} | |
}); | |
// Handle errors | |
xhr.addEventListener('error', function() { | |
console.error('Network Error'); | |
console.groupEnd(); | |
delete activeRequests[requestId]; | |
}); | |
xhr.addEventListener('abort', function() { | |
console.warn('Request Aborted'); | |
console.groupEnd(); | |
delete activeRequests[requestId]; | |
}); | |
// Finally, send the request | |
return originalSend.call(xhr, bodyToSend); | |
}; | |
// Check if beforeSend returns a Promise | |
const beforeSendResult = beforeSend(body, requestData); | |
if (beforeSendResult instanceof Promise) { | |
// Handle async beforeSend (will wait for the Promise to resolve) | |
beforeSendResult | |
.then(modifiedBody => { | |
requestData.body = modifiedBody || body; | |
return sendRequest(requestData.body); | |
}) | |
.catch(error => { | |
console.error('Error in beforeSend hook:', error); | |
// Send the original body if there's an error | |
return sendRequest(body); | |
}); | |
} else { | |
// Handle synchronous beforeSend | |
requestData.body = beforeSendResult || body; | |
return sendRequest(requestData.body); | |
} | |
}; | |
return xhr; | |
}; | |
// Log that the interceptor is installed | |
console.log( | |
'%cXHR Interceptor with Side Requests Installed!%c You can now make additional network requests during interception.', | |
'background: #9C27B0; color: white; padding: 2px 5px; border-radius: 3px;', | |
'color: inherit;' | |
); | |
// Function to restore original XHR | |
window.restoreOriginalXHR = function() { | |
window.XMLHttpRequest = OriginalXHR; | |
console.log( | |
'%cXHR Interceptor Removed!%c Original XMLHttpRequest restored.', | |
'background: #dc3545; color: white; padding: 2px 5px; border-radius: 3px;', | |
'color: inherit;' | |
); | |
delete window.restoreOriginalXHR; | |
}; | |
// Expose side request methods globally (for testing) | |
window.xhrInterceptor = { | |
makeAsyncSideRequest, | |
makeSyncSideRequest, | |
makeXHRSideRequest, | |
id: interceptorId | |
}; | |
return `XHR interceptor with side request capability installed (ID: ${interceptorId}). | |
- Use window.xhrInterceptor.makeAsyncSideRequest() for non-blocking requests | |
- Use window.xhrInterceptor.makeSyncSideRequest() for blocking requests | |
- Use window.xhrInterceptor.makeXHRSideRequest() for XHR-based requests | |
Run window.restoreOriginalXHR() to disable.`; | |
})(); | |
</script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment