🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
299 lines
9.2 KiB
JavaScript
299 lines
9.2 KiB
JavaScript
/**
|
|
* JQHTML SSR HTTP Interception
|
|
*
|
|
* Intercepts fetch() and XMLHttpRequest to rewrite relative URLs
|
|
* using the configured baseUrl.
|
|
*/
|
|
|
|
const http = require('http');
|
|
const https = require('https');
|
|
|
|
/**
|
|
* Resolve a URL relative to a base URL
|
|
* @param {string} url - URL to resolve (may be relative)
|
|
* @param {string} baseUrl - Base URL (e.g., "https://example.com")
|
|
* @returns {string} Absolute URL
|
|
*/
|
|
function resolveUrl(url, baseUrl) {
|
|
// Already absolute with protocol
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
return url;
|
|
}
|
|
|
|
// Protocol-relative (//cdn.example.com/...)
|
|
if (url.startsWith('//')) {
|
|
const baseProtocol = new URL(baseUrl).protocol;
|
|
return baseProtocol + url;
|
|
}
|
|
|
|
// Absolute path (/api/users)
|
|
if (url.startsWith('/')) {
|
|
const base = new URL(baseUrl);
|
|
return `${base.protocol}//${base.host}${url}`;
|
|
}
|
|
|
|
// Relative path (api/users)
|
|
const base = new URL(baseUrl);
|
|
const basePath = base.pathname.endsWith('/') ? base.pathname : base.pathname + '/';
|
|
return `${base.protocol}//${base.host}${basePath}${url}`;
|
|
}
|
|
|
|
/**
|
|
* Create a fetch function that rewrites URLs
|
|
* @param {string} baseUrl - Base URL for relative URL resolution
|
|
* @returns {function} Configured fetch function
|
|
*/
|
|
function createFetch(baseUrl) {
|
|
// Use native fetch if available (Node 18+), otherwise use a simple implementation
|
|
const nativeFetch = globalThis.fetch;
|
|
|
|
if (nativeFetch) {
|
|
return async function ssrFetch(url, options = {}) {
|
|
const resolvedUrl = resolveUrl(String(url), baseUrl);
|
|
|
|
// Remove any auth-related headers for SSR (SEO mode)
|
|
const safeHeaders = { ...(options.headers || {}) };
|
|
delete safeHeaders['Authorization'];
|
|
delete safeHeaders['authorization'];
|
|
delete safeHeaders['Cookie'];
|
|
delete safeHeaders['cookie'];
|
|
|
|
return nativeFetch(resolvedUrl, {
|
|
...options,
|
|
headers: safeHeaders
|
|
});
|
|
};
|
|
}
|
|
|
|
// Fallback for older Node versions - basic fetch implementation
|
|
return function ssrFetch(url, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const resolvedUrl = resolveUrl(String(url), baseUrl);
|
|
const parsedUrl = new URL(resolvedUrl);
|
|
const isHttps = parsedUrl.protocol === 'https:';
|
|
const lib = isHttps ? https : http;
|
|
|
|
const requestOptions = {
|
|
hostname: parsedUrl.hostname,
|
|
port: parsedUrl.port || (isHttps ? 443 : 80),
|
|
path: parsedUrl.pathname + parsedUrl.search,
|
|
method: options.method || 'GET',
|
|
headers: options.headers || {}
|
|
};
|
|
|
|
// Remove auth headers
|
|
delete requestOptions.headers['Authorization'];
|
|
delete requestOptions.headers['authorization'];
|
|
delete requestOptions.headers['Cookie'];
|
|
delete requestOptions.headers['cookie'];
|
|
|
|
const req = lib.request(requestOptions, (res) => {
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => {
|
|
resolve({
|
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
status: res.statusCode,
|
|
statusText: res.statusMessage,
|
|
headers: new Map(Object.entries(res.headers)),
|
|
text: () => Promise.resolve(data),
|
|
json: () => Promise.resolve(JSON.parse(data))
|
|
});
|
|
});
|
|
});
|
|
|
|
req.on('error', reject);
|
|
|
|
if (options.body) {
|
|
req.write(options.body);
|
|
}
|
|
|
|
req.end();
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create an XMLHttpRequest class that rewrites URLs
|
|
* This is needed for jQuery $.ajax compatibility
|
|
* @param {string} baseUrl - Base URL for relative URL resolution
|
|
* @param {function} OriginalXHR - Original XMLHttpRequest constructor from jsdom
|
|
* @returns {function} Wrapped XMLHttpRequest constructor
|
|
*/
|
|
function createXHRWrapper(baseUrl, OriginalXHR) {
|
|
return class SSR_XMLHttpRequest extends OriginalXHR {
|
|
open(method, url, async = true, user, password) {
|
|
const resolvedUrl = resolveUrl(String(url), baseUrl);
|
|
return super.open(method, resolvedUrl, async, user, password);
|
|
}
|
|
|
|
setRequestHeader(name, value) {
|
|
// Block auth headers for SSR
|
|
const lowerName = name.toLowerCase();
|
|
if (lowerName === 'authorization' || lowerName === 'cookie') {
|
|
return;
|
|
}
|
|
return super.setRequestHeader(name, value);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Wrap an existing fetch function to inject the SSR token header on all requests
|
|
* @param {function} fetchFn - The fetch function to wrap
|
|
* @param {string} ssrToken - SSR token value
|
|
* @returns {function} Wrapped fetch function
|
|
*/
|
|
function wrapFetchWithSsrToken(fetchFn, ssrToken) {
|
|
return function ssrTokenFetch(url, options = {}) {
|
|
const headers = { ...(options.headers || {}) };
|
|
headers['X-SSR-Token'] = ssrToken;
|
|
return fetchFn(url, { ...options, headers });
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Install SSR token injection on fetch and XHR
|
|
* @param {Window} window - jsdom window object
|
|
* @param {string} ssrToken - SSR token value
|
|
*/
|
|
function installSsrTokenIntercept(window, ssrToken) {
|
|
if (!ssrToken) return;
|
|
|
|
// Wrap fetch with SSR token
|
|
const currentFetch = window.fetch;
|
|
window.fetch = wrapFetchWithSsrToken(currentFetch, ssrToken);
|
|
|
|
if (typeof global !== 'undefined') {
|
|
global.fetch = window.fetch;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a custom jQuery $.ajaxTransport that uses Node's fetch() for real HTTP.
|
|
*
|
|
* This solves the problem of jsdom's XMLHttpRequest not making real network requests.
|
|
* jQuery's $.ajax() will use this transport instead of XHR, so the chain becomes:
|
|
* $.ajax() → ajaxTransport → Node fetch() → real HTTP request
|
|
*
|
|
* @param {Window} window - jsdom window object (must have jQuery on window.$)
|
|
* @param {string} baseUrl - Base URL for resolving relative URLs
|
|
* @param {string} [ssrToken] - Optional SSR token to include in requests
|
|
*/
|
|
function installAjaxTransport(window, baseUrl, ssrToken) {
|
|
const $ = window.$;
|
|
if (!$ || !$.ajaxTransport) return;
|
|
|
|
$.ajaxTransport('+*', function(options, originalOptions, jqXHR) {
|
|
return {
|
|
send: function(headers, completeCallback) {
|
|
// Build absolute URL
|
|
const url = resolveUrl(options.url || '', baseUrl);
|
|
|
|
// Build fetch options
|
|
const fetchHeaders = {};
|
|
|
|
// Copy headers from jQuery, stripping auth headers
|
|
for (const [name, value] of Object.entries(headers)) {
|
|
const lowerName = name.toLowerCase();
|
|
if (lowerName === 'authorization' || lowerName === 'cookie') continue;
|
|
fetchHeaders[name] = value;
|
|
}
|
|
|
|
// Inject SSR token if provided
|
|
if (ssrToken) {
|
|
fetchHeaders['X-SSR-Token'] = ssrToken;
|
|
}
|
|
|
|
// Set content type if not already set
|
|
if (options.contentType && !fetchHeaders['Content-Type']) {
|
|
fetchHeaders['Content-Type'] = options.contentType;
|
|
}
|
|
|
|
const fetchOptions = {
|
|
method: options.type || 'GET',
|
|
headers: fetchHeaders
|
|
};
|
|
|
|
// Add body for non-GET requests
|
|
if (options.data && options.type && options.type.toUpperCase() !== 'GET') {
|
|
fetchOptions.body = typeof options.data === 'string'
|
|
? options.data
|
|
: JSON.stringify(options.data);
|
|
}
|
|
|
|
// Use the globally available fetch (already has URL rewriting from createFetch)
|
|
const fetchFn = globalThis.fetch || window.fetch;
|
|
|
|
fetchFn(url, fetchOptions)
|
|
.then(async (response) => {
|
|
const responseText = await response.text();
|
|
const responseHeaders = {};
|
|
if (response.headers && typeof response.headers.forEach === 'function') {
|
|
response.headers.forEach((value, name) => {
|
|
responseHeaders[name] = value;
|
|
});
|
|
}
|
|
|
|
const headerString = Object.entries(responseHeaders)
|
|
.map(([k, v]) => `${k}: ${v}`)
|
|
.join('\r\n');
|
|
|
|
completeCallback(
|
|
response.status,
|
|
response.statusText,
|
|
{ text: responseText },
|
|
headerString
|
|
);
|
|
})
|
|
.catch((err) => {
|
|
completeCallback(0, 'Network Error: ' + err.message, {}, '');
|
|
});
|
|
},
|
|
abort: function() {
|
|
// Cannot abort native fetch, but jQuery expects this method
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Install HTTP interception on a jsdom window
|
|
* @param {Window} window - jsdom window object
|
|
* @param {string} baseUrl - Base URL for URL resolution
|
|
* @param {string} [ssrToken] - Optional SSR token for outgoing requests
|
|
*/
|
|
function installHttpIntercept(window, baseUrl, ssrToken) {
|
|
// Override fetch
|
|
window.fetch = createFetch(baseUrl);
|
|
|
|
// Also set on global for code that accesses global.fetch
|
|
if (typeof global !== 'undefined') {
|
|
global.fetch = window.fetch;
|
|
}
|
|
|
|
// Override XMLHttpRequest if it exists
|
|
if (window.XMLHttpRequest) {
|
|
const WrappedXHR = createXHRWrapper(baseUrl, window.XMLHttpRequest);
|
|
window.XMLHttpRequest = WrappedXHR;
|
|
|
|
if (typeof global !== 'undefined') {
|
|
global.XMLHttpRequest = WrappedXHR;
|
|
}
|
|
}
|
|
|
|
// Install SSR token on fetch/XHR if provided
|
|
if (ssrToken) {
|
|
installSsrTokenIntercept(window, ssrToken);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
resolveUrl,
|
|
createFetch,
|
|
createXHRWrapper,
|
|
installHttpIntercept,
|
|
installSsrTokenIntercept,
|
|
installAjaxTransport
|
|
};
|