SPB Git

spb/tendril Public

Tendril — web ingestion platform (scrape/crawl/map/search) on macOS Apple Silicon: WebKit fidelity, authenticated pages, deterministic testable extraction. A self-hosted Firecrawl alternative.

JavaScript 82.6% TypeScript 11.8% HTML 5.3%
6.1 KB · 193 lines typescript
Raw Blame History
1// author: simon-pierre boucher <contact@spboucher.ai>2import { Agent, request } from "undici";3import type { LookupFunction } from "node:net";4import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";5import {6  err,7  normalizeUrl,8  ok,9  tendrilError,10  type FetchResult,11  type RedirectHop,12  type Result,13} from "@tendril/shared";14import { validateEgress, type Resolver } from "@tendril/egress";15import { buildHeaders } from "./headers.js";1617const MAX_REDIRECTS = 5;18const DEFAULT_MAX_BYTES = 20 * 1024 * 1024;1920const pinnedAddresses = new Map<string, string>();2122const pinnedLookup: LookupFunction = (hostname, options, callback) => {23  const pinned = pinnedAddresses.get(hostname);24  if (pinned === undefined) {25    callback(new Error(`no pinned address for ${hostname}`) as NodeJS.ErrnoException, "");26    return;27  }28  const family = pinned.includes(":") ? 6 : 4;29  if (options.all === true) {30    callback(null, [{ address: pinned, family }]);31  } else {32    callback(null, pinned, family);33  }34};3536const agent = new Agent({37  connections: 64,38  pipelining: 1,39  keepAliveTimeout: 30_000,40  keepAliveMaxTimeout: 120_000,41  bodyTimeout: 20_000,42  headersTimeout: 10_000,43  connect: {44    timeout: 8_000,45    rejectUnauthorized: true,46    lookup: pinnedLookup,47  },48});4950export interface HttpFetchOptions {51  readonly timeout?: number;52  readonly maxBytes?: number;53  readonly headers?: Readonly<Record<string, string>>;54  readonly userAgent?: string;55  readonly resolver?: Resolver;56}5758function decompress(buf: Buffer, encoding: string | undefined): Buffer {59  try {60    switch ((encoding ?? "").toLowerCase()) {61      case "gzip":62        return gunzipSync(buf);63      case "deflate":64        return inflateSync(buf);65      case "br":66        return brotliDecompressSync(buf);67      default:68        return buf;69    }70  } catch {71    return buf;72  }73}7475async function readCapped(body: AsyncIterable<Buffer>, max: number): Promise<Buffer | null> {76  const chunks: Buffer[] = [];77  let total = 0;78  for await (const chunk of body) {79    total += chunk.length;80    if (total > max) return null;81    chunks.push(chunk);82  }83  return Buffer.concat(chunks);84}8586const META_REFRESH_RE = /<meta[^>]+http-equiv=["']?refresh["']?[^>]*content=["'][^"']*url=([^"'>\s]+)/i;8788function firstHeader(value: string | string[] | undefined): string | undefined {89  if (Array.isArray(value)) return value[0];90  return value;91}9293/**94 * Tier 0 HTTP fetch (§3). Handles redirects manually so it can re-validate SSRF95 * on every hop (§16.5), record the full chain, detect meta-refresh redirects the96 * transport cannot see, and stop on a redirect loop. Connections are pinned to97 * the SSRF-validated IP to defeat DNS rebinding between validation and connect.98 */99export async function httpFetch(rawUrl: string, options: HttpFetchOptions = {}): Promise<Result<FetchResult>> {100  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;101  const redirects: RedirectHop[] = [];102  const seen = new Set<string>();103104  let current = rawUrl;105  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {106    const norm = normalizeUrl(current);107    if (!norm.ok) return norm;108    if (seen.has(norm.value)) return err(tendrilError("ERR_REDIRECT_LOOP", { details: { url: current } }));109    seen.add(norm.value);110111    const safe = await validateEgress(current, options.resolver);112    if (!safe.ok) return safe;113    const address = safe.value.addresses[0];114    if (address === undefined) return err(tendrilError("ERR_SSRF_BLOCKED", { details: { host: safe.value.host } }));115    pinnedAddresses.set(safe.value.host, address);116117    let res: Awaited<ReturnType<typeof request>>;118    try {119      res = await request(current, {120        dispatcher: agent,121        method: "GET",122        headersTimeout: options.timeout ?? 10_000,123        bodyTimeout: options.timeout ?? 20_000,124        headers: buildHeaders(safe.value.host, options.headers, options.userAgent),125      });126    } catch (cause) {127      return err(tendrilError("ERR_TARGET_5XX", { message: "Transport error", details: { url: current }, cause }));128    }129130    const status = res.statusCode;131    const location = firstHeader(res.headers["location"]);132133    if (status >= 300 && status < 400 && location !== undefined && location !== "") {134      res.body.dump().catch(() => undefined);135      if (hop === MAX_REDIRECTS) return err(tendrilError("ERR_REDIRECT_LOOP", { details: { url: current } }));136      let next: string;137      try {138        next = new URL(location, current).toString();139      } catch {140        return err(tendrilError("ERR_INVALID_URL", { details: { location } }));141      }142      redirects.push({ from: current, to: next, status });143      current = next;144      continue;145    }146147    const rawBody = await readCapped(res.body, maxBytes);148    if (rawBody === null) return err(tendrilError("ERR_TOO_LARGE", { details: { max: maxBytes } }));149150    const contentType = (firstHeader(res.headers["content-type"]) ?? "").toLowerCase();151    const encoding = firstHeader(res.headers["content-encoding"]);152    const decoded = decompress(rawBody, encoding);153    const bodyText = decoded.toString("utf8");154155    const isHtml = contentType.includes("text/html") || contentType.includes("xhtml") || contentType === "";156    if (isHtml && hop < MAX_REDIRECTS) {157      const meta = META_REFRESH_RE.exec(bodyText);158      if (meta?.[1] !== undefined) {159        let next: string;160        try {161          next = new URL(meta[1], current).toString();162        } catch {163          next = "";164        }165        if (next !== "" && !seen.has(normalizeUrl(next).ok ? (normalizeUrl(next) as { value: string }).value : next)) {166          redirects.push({ from: current, to: next, status: 200 });167          current = next;168          continue;169        }170      }171    }172173    const headers: Record<string, string> = {};174    for (const [key, value] of Object.entries(res.headers)) {175      headers[key] = Array.isArray(value) ? value.join(", ") : (value ?? "");176    }177178    const result: FetchResult = {179      tier: "http",180      status,181      finalUrl: current,182      contentType,183      body: bodyText,184      bodyBytes: decoded.length,185      redirects,186      headers,187    };188    return ok(result);189  }190191  return err(tendrilError("ERR_REDIRECT_LOOP", { details: { url: rawUrl } }));192}193