// just-bash's curl uses this browser transport. There is no proxy/server. export const HTTP_METHODS = Object.freeze(['GET', 'HEAD', 'POST']); export const HTTP_LIMIT = 1024 * 1024; export const HTTP_TIMEOUT = 8_000; export function createBrowserFetch({ transport = globalThis.fetch, timeoutMs = HTTP_TIMEOUT, maxBytes = HTTP_LIMIT } = {}) { return async (input, options = {}) => { const url = new URL(input); if (url.protocol !== 'https:') throw Error('Only HTTPS URLs are supported. Use https:// instead.'); if (url.username || url.password) throw Error('Credentials in URLs are unsupported. Use an explicit Authorization header if needed.'); const method = (options.method ?? 'GET').toUpperCase(); if (!HTTP_METHODS.includes(method)) throw Error(`HTTP method ${method} is not enabled. Use GET, HEAD, or POST.`); const requestedTimeout = options.timeoutMs > 0 ? options.timeoutMs : timeoutMs; const controller = new AbortController(); const deadline = Math.min(requestedTimeout, timeoutMs); const timer = setTimeout(() => controller.abort(new DOMException(`HTTP request timed out after ${deadline} ms.`, 'TimeoutError')), deadline); const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal; let reader; try { signal.throwIfAborted(); const response = await transport(url.href, { method, headers: options.headers, body: ['GET', 'HEAD'].includes(method) ? undefined : options.body, mode: 'cors', credentials: 'omit', redirect: 'manual', referrerPolicy: 'no-referrer', signal }); // Browsers hide both the Location header and status on manual redirects. // Following automatically would skip the HTTPS check at intermediate hops. if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400 && response.status !== 304)) { throw Error('HTTP redirect cannot be followed safely in this browser shell. Use the final HTTPS URL directly (including exact capitalization).'); } if (response.status === 0 || response.type === 'opaque') throw Error('HTTP response is unreadable. The destination must allow browser access with CORS.'); if (method !== 'HEAD' && Number(response.headers.get('content-length')) > maxBytes) { await response.body?.cancel(); throw Error(`HTTP response exceeds the ${maxBytes}-byte limit.`); } const chunks = []; let size = 0; if (response.body) { reader = response.body.getReader(); while (true) { const { done, value } = await reader.read(); signal.throwIfAborted(); if (done) break; size += value.byteLength; if (size > maxBytes) { await reader.cancel(); throw Error(`HTTP response exceeds the ${maxBytes}-byte limit.`); } chunks.push(value); } } const body = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; } return { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()), body, url: response.url || url.href }; } catch (error) { if (signal.aborted) throw signal.reason; if (error instanceof TypeError) throw Error('HTTP request failed: the server may block browser requests (CORS), or the connection failed. No proxy is used.'); throw error; } finally { clearTimeout(timer); reader?.releaseLock(); } }; }