/** Fast HTTP fetch via undici + SSRF guard. */ import { fetch } from "undici"; import { validateUrl, timeoutSignal } from "../utils.js"; import { fetchWithMetaRefresh } from "./hints.js"; export interface FetchResult { url: string; finalUrl: string; status: number; contentType: string; html: string; bytes: number; truncated?: boolean; } /** Real browser UA — community sites often serve different shells to bot UAs. */ export const DEFAULT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; export const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; /** Floor so models passing tiny maxBytes (e.g. 200_000) don't fail on normal pages. */ export const MIN_MAX_BYTES = 2 * 1024 * 1024; export function resolveMaxBytes(requested?: number): number { if (requested === undefined || requested <= 0) return DEFAULT_MAX_BYTES; return Math.max(requested, MIN_MAX_BYTES); } /** Try to resolve charset; return "utf-8" on any failure. */ function resolveCharset(contentType: string, bytes: Uint8Array): string { // (a) Content-Type header — honour for all MIME types const ct = /charset\s*=\s*["']?([\w-]+)/i.exec(contentType); if (ct?.[1]) return ct[1]; // (b) is an HTML mechanism — never sniff text/plain, JSON, etc. // A markdown/docs body that merely *mentions* must // stay utf-8; otherwise the whole body is destroyed. const mime = contentType.split(";")[0].trim().toLowerCase(); if (mime && !mime.includes("html") && !mime.includes("xml")) return "utf-8"; // (c) in first 2048 bytes — decode as latin1 (lossless for single-byte) const preview = new TextDecoder("iso-8859-1").decode( bytes.slice(0, 2048), ); const meta = /]+(?:charset\s*=\s*["']?([\w-]+)|content\s*=\s*["'][^"']*charset\s*=\s*([\w-]+))/i.exec(preview); if (meta?.[1] || meta?.[2]) return meta[1] ?? meta[2] ?? "utf-8"; // (d) Default return "utf-8"; } /** * Read a fetch Response body up to maxBytes, then cancel the stream. * Falls back to arrayBuffer slice when body is unavailable. */ export async function readBodyCapped( response: { body?: { getReader: () => ReadableStreamDefaultReader } | null; arrayBuffer: () => Promise; headers?: { get: (n: string) => string | null }; }, maxBytes: number, ): Promise<{ text: string; bytes: number; truncated: boolean }> { const body = response.body; const contentType = response.headers?.get("content-type") ?? ""; if (!body || typeof body.getReader !== "function") { const ab = await response.arrayBuffer(); const truncated = ab.byteLength > maxBytes; const slice = truncated ? ab.slice(0, maxBytes) : ab; const buf = Buffer.from(slice); const raw = new Uint8Array(buf); const charset = resolveCharset(contentType, raw); const text = decodeBuffer(buf, charset); return { text, bytes: Math.min(ab.byteLength, maxBytes + (truncated ? 1 : 0)), truncated, }; } const reader = body.getReader(); const chunks: Uint8Array[] = []; let total = 0; let truncated = false; try { while (true) { const { done, value } = await reader.read(); if (done) break; if (!value?.byteLength) continue; if (total >= maxBytes) { truncated = true; break; } const room = maxBytes - total; if (value.byteLength <= room) { chunks.push(value); total += value.byteLength; } else { chunks.push(value.subarray(0, room)); total += room; truncated = true; break; } } } finally { try { await reader.cancel(); } catch { // ignore } } const raw = Buffer.concat(chunks.map((c) => Buffer.from(c))); const charset = resolveCharset(contentType, new Uint8Array(raw)); const text = decodeBuffer(raw, charset); return { text, bytes: total, truncated }; } /** Decode a buffer using the given charset label. Falls back to utf-8. */ function decodeBuffer(buf: Buffer, charset: string): string { try { return new TextDecoder(charset, { fatal: false }).decode(buf); } catch { // Unknown label — RangeError return new TextDecoder("utf-8").decode(buf); } } async function fetchUrlOnce( url: string, options: { signal?: AbortSignal; timeoutMs?: number; maxBytes?: number; headers?: Record; }, /** Original request URL preserved across meta-refresh hops. */ originalUrl: string, ): Promise { const ssrf = validateUrl(url); if (ssrf) throw new Error(ssrf); const maxBytes = resolveMaxBytes(options.maxBytes); const signal = timeoutSignal(options.signal, options.timeoutMs); const response = await fetch(url, { method: "GET", headers: { "user-agent": DEFAULT_UA, accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "accept-language": "en-US,en;q=0.9", ...options.headers, }, redirect: "follow", signal, }); const finalUrl = response.url || url; const finalSsrf = validateUrl(finalUrl); if (finalSsrf) throw new Error(finalSsrf); const contentType = response.headers.get("content-type") ?? "text/html"; const { text, bytes, truncated } = await readBodyCapped(response, maxBytes); return { url: originalUrl, finalUrl, status: response.status, contentType, html: text, bytes, truncated, }; } export async function fetchUrl( url: string, options: { signal?: AbortSignal; timeoutMs?: number; maxBytes?: number; headers?: Record; } = {}, ): Promise { return fetchWithMetaRefresh(url, (hop) => fetchUrlOnce(hop, options, url)); }