Files
clawd/node_modules/jsdom/lib/jsdom/browser/resources/request-interceptor.js

172 lines
5.9 KiB
JavaScript

"use strict";
const { Readable } = require("stream");
const { sendStreamResponse } = require("./stream-handler");
/**
* Creates a user-friendly `undici` interceptor for jsdom.
*
* This helper allows users to intercept requests using a callback that receives a `Request` object and can return a
* promise for a `Response` to provide a synthetic response, or a promise for `undefined` to pass through.
*
* @param {function} fn - Async callback function that receives (request, context) and can return a Response
* @returns {function} An undici interceptor
*
* @example
* const dom = new JSDOM(html, {
* interceptors: [
* requestInterceptor(async (request, { element }) => {
* console.log(`${element?.localName || 'XHR'} requested ${request.url}`);
* if (request.url.endsWith('/test.js')) {
* return new Response('window.mocked = true;', {
* headers: { 'Content-Type': 'application/javascript' }
* });
* }
* // Return undefined to let the request pass through
* })
* ]
* });
*
* ## Why this doesn't use undici's DecoratorHandler pattern
*
* The standard undici interceptor pattern (see e.g. undici/lib/interceptor/dump.js) uses DecoratorHandler
* to wrap handler callbacks. That pattern calls `dispatch()` synchronously, then observes/modifies the
* request and response as they flow through the handler callbacks.
*
* This interceptor needs to support async user functions that can BLOCK the request and potentially
* REPLACE it with a synthetic response. This requires:
*
* 1. Waiting for the async user function BEFORE deciding whether to dispatch
* 2. For synthetic responses, NEVER calling dispatch() at all
*
* Since synthetic responses bypass the normal undici dispatch flow entirely, there's no underlying
* dispatcher (Agent/Pool) to create a real controller for us. We provide our own controller object
* that delegates to a Node.js Readable stream for pause/resume/abort support, following the pattern
* from undici/lib/interceptor/cache.js.
*/
module.exports = function requestInterceptor(fn) {
return dispatch => (options, handler) => {
const { element = null, url } = options.opaque || {};
const abortController = new AbortController();
// Create undici controller and wrapped handler using the signal handler helper.
// The undici controller reflects abortController.signal's state and forwards to inner undici controller.
const { undiciController, wrappedHandler } = createSignalHandler(handler, abortController);
// Call onRequestStart immediately to wire into the abort chain.
handler.onRequestStart?.(undiciController, {});
// Build Request object with our signal
const requestInit = {
method: options.method || "GET",
headers: options.headers,
signal: abortController.signal
};
if (options.body !== undefined && options.body !== null) {
requestInit.body = options.body;
requestInit.duplex = "half";
}
if (options.referrer) {
requestInit.referrer = options.referrer;
}
const request = new Request(url, requestInit);
new Promise(resolve => {
resolve(fn(request, { element }));
})
.then(response => {
if (response instanceof Response) {
// Send synthetic response without ever starting real request
// response.body can be null for responses with no body
const stream = response.body ? Readable.fromWeb(response.body) : Readable.from([]);
sendStreamResponse(wrappedHandler, stream, {
status: response.status,
statusText: response.statusText,
headers: headersToUndici(response.headers)
});
} else if (response !== undefined) {
throw new TypeError("requestInterceptor callback must return undefined or a Response");
} else if (!abortController.signal.aborted) {
// Pass through to real request
dispatch(options, wrappedHandler);
}
})
.catch(error => {
handler.onResponseError?.(undiciController, error);
});
// Return true to indicate request is being handled
return true;
};
};
/**
* Creates an undici controller and wrapped handler that bridge an AbortController to undici's dispatch protocol.
*
* The undici controller reflects the AbortController's signal state and captures an inner undici controller
* (from pass-through dispatch or synthetic stream) for forwarding pause/resume/abort.
*/
function createSignalHandler(handler, abortController) {
let innerUndiciController = null;
const undiciController = {
abort(reason) {
abortController.abort(reason);
innerUndiciController?.abort(reason);
},
pause() {
innerUndiciController?.pause();
},
resume() {
innerUndiciController?.resume();
},
get paused() {
return innerUndiciController?.paused ?? false;
},
get aborted() {
return abortController.signal.aborted;
},
get reason() {
return abortController.signal.reason;
}
};
const wrappedHandler = {
onRequestStart(controller) {
innerUndiciController = controller;
},
onRequestUpgrade(...args) {
handler.onRequestUpgrade?.(...args);
},
onResponseStart(...args) {
handler.onResponseStart?.(...args);
},
onResponseData(...args) {
handler.onResponseData?.(...args);
},
onResponseEnd(...args) {
handler.onResponseEnd?.(...args);
},
onResponseError(...args) {
handler.onResponseError?.(...args);
}
};
return { undiciController, wrappedHandler };
}
/**
* Converts a Headers object to the format undici expects.
* Handles multiple Set-Cookie headers via getSetCookie().
*/
function headersToUndici(headers) {
const result = {};
for (const [key, value] of headers) {
result[key] = value;
}
const cookies = headers.getSetCookie();
if (cookies.length > 0) {
result["set-cookie"] = cookies;
}
return result;
}