| |
| 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 }); |
| |
| |
| 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(); } |
| }; |
| } |
|
|