import { HostBudgets, type HostBudgetOptions } from "./budget.js"; import { EgressError } from "./errors.js"; import { EgressPolicy, type EgressPolicyOptions } from "./policy.js"; import { requestPinned } from "./transport.js"; export interface EgressClientOptions extends HostBudgetOptions, EgressPolicyOptions { timeoutMs?: number; maxResponseBytes?: number; maxRequestBytes?: number; maxRedirects?: number; } export interface EgressRequestOptions { hostClass: string; /** true pins to the initial origin; a URL string explicitly pins to that origin. */ pinnedOrigin?: boolean | string | URL; } const redirectStatuses = new Set([301, 302, 303, 307, 308]); export function createEgressClient(options: EgressClientOptions) { const policy = new EgressPolicy(options); const budgets = new HostBudgets(options); const timeoutMs = options.timeoutMs ?? 15_000; const maxResponseBytes = options.maxResponseBytes ?? 8 * 1024 * 1024; const maxRequestBytes = options.maxRequestBytes ?? 8 * 1024 * 1024; const maxRedirects = options.maxRedirects ?? 5; if (![timeoutMs, maxResponseBytes, maxRequestBytes].every((value) => Number.isSafeInteger(value) && value > 0) || !Number.isSafeInteger(maxRedirects) || maxRedirects < 0) { throw new EgressError("INVALID_CONFIGURATION", "Invalid request limits"); } async function egressFetch(raw: string | URL, init: RequestInit = {}, context: EgressRequestOptions): Promise { const controller = new AbortController(); const abort = () => controller.abort(new EgressError("ABORTED", "Request aborted")); init.signal?.addEventListener("abort", abort, { once: true }); if (init.signal?.aborted) abort(); const timer = setTimeout(() => controller.abort(new EgressError("TIMEOUT", "Egress deadline exceeded")), timeoutMs); const check = () => { if (controller.signal.aborted) throw controller.signal.reason; }; let abortListener: (() => void) | undefined; const cancelled = new Promise((_resolve, reject) => { abortListener = () => reject(controller.signal.reason); controller.signal.addEventListener("abort", abortListener, { once: true }); if (controller.signal.aborted) abortListener(); }); const run = async () => { check(); let url = policy.validateUrl(raw); const pinnedOrigin = context.pinnedOrigin === true ? url.origin : context.pinnedOrigin ? policy.validateUrl(context.pinnedOrigin).origin : undefined; if (pinnedOrigin && url.origin !== pinnedOrigin) throw new EgressError("ORIGIN_DENIED", "Request is outside the pinned origin"); // Request provides fetch method/header/body normalization; the transport owns connection options. let normalized: Request; try { normalized = new Request(url, init); } catch { throw new EgressError("INVALID_REQUEST", "Invalid fetch request initialization"); } let method = normalized.method; const headers = new Headers(normalized.headers); let body: Buffer | undefined; if (normalized.body) { const reader = normalized.body.getReader(); const cancelBody = () => { void reader.cancel().catch(() => undefined); }; controller.signal.addEventListener("abort", cancelBody, { once: true }); const chunks: Buffer[] = []; let size = 0; try { for (;;) { check(); const { value, done } = await reader.read(); if (done) break; size += value.length; if (size > maxRequestBytes) throw new EgressError("REQUEST_TOO_LARGE", "Request exceeds size limit"); chunks.push(Buffer.from(value)); } body = Buffer.concat(chunks); } finally { controller.signal.removeEventListener("abort", cancelBody); void reader.cancel().catch(() => undefined); } } const visited = new Set(); for (let hop = 0; ; hop++) { check(); if (visited.has(url.href)) throw new EgressError("REDIRECT_LOOP", "Redirect cycle detected"); visited.add(url.href); const endpoint = await policy.validate(url); check(); await budgets.consume(endpoint.hostKey, context.hostClass); check(); const wire = await requestPinned(endpoint, method, headers, body, controller.signal, maxResponseBytes, (status, responseHeaders) => budgets.observe(endpoint.hostKey, context.hostClass, status, responseHeaders)); check(); const location = wire.headers.get("location"); if (!redirectStatuses.has(wire.status) || !location || init.redirect === "manual") { const response = new Response(method === "HEAD" || [204, 205, 304].includes(wire.status) ? null : new Uint8Array(wire.body), { status: wire.status, statusText: wire.statusText, headers: wire.headers }); Object.defineProperties(response, { url: { value: url.href }, redirected: { value: hop > 0 } }); return response; } if (init.redirect === "error" || hop >= maxRedirects) throw new EgressError("REDIRECT_LOOP", "Redirect limit exceeded"); if (/[\s\\\u0000-\u001f\u007f]/u.test(location)) throw new EgressError("INVALID_URL", "Malformed redirect target"); let target: URL; try { target = policy.validateUrl(new URL(location, url)); } catch (error) { if (error instanceof EgressError) throw error; throw new EgressError("INVALID_URL", "Invalid redirect target"); } if (pinnedOrigin && target.origin !== pinnedOrigin) throw new EgressError("ORIGIN_DENIED", "Redirect is outside the pinned origin"); if (target.origin !== url.origin) { for (const name of ["authorization", "proxy-authorization", "cookie", "cookie2"]) headers.delete(name); // A body can contain secrets just as headers can; refuse to replay it off-origin. if (body && (wire.status === 307 || wire.status === 308 || method !== "POST" && wire.status !== 303)) { throw new EgressError("ORIGIN_DENIED", "Request body cannot be redirected off-origin"); } } if (wire.status === 303 && method !== "HEAD" || (wire.status === 301 || wire.status === 302) && method === "POST") { method = "GET"; body = undefined; for (const name of ["content-type", "content-length", "content-encoding", "content-language", "content-location"]) headers.delete(name); } url = target; } }; try { return await Promise.race([run(), cancelled]); } catch (error) { throw error instanceof EgressError ? error : new EgressError("NETWORK_ERROR", "Egress request failed"); } finally { clearTimeout(timer); init.signal?.removeEventListener("abort", abort); if (abortListener) controller.signal.removeEventListener("abort", abortListener); } } return { egressFetch, policy, budgets }; } /** Stateless convenience form. Reuse createEgressClient when pool/config are fixed. */ export function egressFetch(url: string | URL, init: RequestInit = {}, options: EgressClientOptions & EgressRequestOptions): Promise { return createEgressClient(options).egressFetch(url, init, options); }