/** * Generic HTTP fetch with caching and zip extraction utilities. * * Provides the download + cache + zip pattern used by lexicon codegen * pipelines that fetch schemas from remote sources. */ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, unlinkSync, rmSync } from "fs"; import { dirname, join } from "path"; import { debug } from "../cli/debug"; // ── Types ────────────────────────────────────────────────────────── export interface FetchConfig { /** URL to fetch. */ url: string; /** Local file path for caching the download. */ cacheFile: string; /** Cache TTL in milliseconds (default: 24 hours). */ cacheTtlMs?: number; } // ── Transient-aware fetch ────────────────────────────────────────── /** * HTTP statuses worth retrying. These are transient: a gateway hiccup, * a rate limit, or an overloaded upstream — not a permanent 404/403. */ const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); const DEFAULT_RETRIES = 4; const DEFAULT_BACKOFF_MS = 1000; /** * Per-attempt ceiling, so an upstream that accepts a connection and then stops * answering cannot hold a build open. * * Without one, a hung connect waits on the OS default — which on Linux is over * a minute — and five of those plus back-off is enough to run a CI job past its * timeout. That is not hypothetical: it is what pushed chant's `check` job from * ~9m30s to a cancellation at 10m, twice, on two unreachable spec endpoints * whose results are only ever a fallback to a committed snapshot anyway. * * Generous enough for a slow-but-alive endpoint; the point is a bound, not * speed. */ const DEFAULT_ATTEMPT_TIMEOUT_MS = 15_000; /** Hard cap on Retry-After delays so a rogue header cannot stall CI indefinitely. */ const MAX_RETRY_AFTER_MS = 60_000; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Parse the `Retry-After` response header and return a delay in milliseconds. * * The header value may be an integer number of seconds or an HTTP-date. * Returns `null` when the header is absent or unparseable. * The returned value is capped at {@link MAX_RETRY_AFTER_MS}. */ function parseRetryAfter(response: Response): number | null { const raw = response.headers.get("retry-after"); if (!raw) return null; const seconds = Number(raw.trim()); if (!Number.isNaN(seconds) && seconds >= 0) { return Math.min(Math.ceil(seconds * 1000), MAX_RETRY_AFTER_MS); } // HTTP-date format: "Wed, 01 Jul 2026 12:00:00 GMT" const date = new Date(raw); if (!Number.isNaN(date.getTime())) { return Math.min(Math.max(0, date.getTime() - Date.now()), MAX_RETRY_AFTER_MS); } return null; } /** * Fetch a URL, retrying on transient failures (network errors and * retryable HTTP statuses) with exponential backoff. * * Permanent failures (e.g. 404, 403) are not retried — they throw on the * first response. The returned response is guaranteed `ok`. * * When a `Retry-After` header is present on a 429 or 503 response the delay * from that header is used instead of the exponential back-off for that * specific retry, capped at {@link MAX_RETRY_AFTER_MS}. * * `init` is passed through to `fetch` on every attempt, so callers that * need request headers (e.g. the GitHub API `Accept` header) or an abort * signal get retries without duplicating the loop. * * On exhaustion a descriptive {@link TransientFetchError} is thrown rather * than the raw last error so the caller can surface a human-readable message. */ export async function fetchWithRetry( url: string, retries = DEFAULT_RETRIES, backoffMs = DEFAULT_BACKOFF_MS, init?: RequestInit, attemptTimeoutMs = DEFAULT_ATTEMPT_TIMEOUT_MS, ): Promise { let lastStatus: number | undefined; let lastError: Error | undefined; // When a response carries a Retry-After header, the indicated delay replaces // the exponential back-off for that specific next retry. let retryAfterOverride: number | null = null; for (let attempt = 0; attempt <= retries; attempt++) { if (attempt > 0) { const delay = retryAfterOverride ?? backoffMs * 2 ** (attempt - 1); debug(`retrying ${url} (attempt ${attempt}/${retries}) after ${delay}ms: ${lastError?.message}`); retryAfterOverride = null; await sleep(delay); } let response: Response; // A caller's own signal still wins; this only bounds an attempt that would // otherwise hang with no signal at all. const signal = init?.signal ?? AbortSignal.timeout(attemptTimeoutMs); try { response = await fetch(url, { ...(init ?? {}), signal }); } catch (e) { // Network-level failure (DNS, connection reset, timeout). Transient. lastError = e instanceof Error ? e : new Error(String(e)); lastStatus = undefined; continue; } if (response.ok) return response; const err = new Error(`Download from ${url} returned ${response.status}`); if (!RETRYABLE_STATUSES.has(response.status)) throw err; lastError = err; lastStatus = response.status; // Capture Retry-After so the next loop iteration uses it instead of backoff const retryAfterMs = parseRetryAfter(response); if (retryAfterMs !== null) { debug(`Retry-After for ${url}: ${retryAfterMs}ms`); retryAfterOverride = retryAfterMs; } } throw new TransientFetchError(url, retries, lastStatus, lastError); } /** * Thrown when {@link fetchWithRetry} exhausts all retry attempts on a * transient failure (429 / 5xx / network error). * * The message is human-readable and actionable: it names the URL, the number * of attempts made, and the last HTTP status (if any) so that an upstream Op * can route it to a report/issue without exposing a raw stack trace. */ export class TransientFetchError extends Error { constructor( public readonly url: string, public readonly retries: number, public readonly lastStatus: number | undefined, public readonly cause?: Error, ) { const attempts = retries + 1; const statusPart = lastStatus !== undefined ? ` (HTTP ${lastStatus})` : ""; super( `Transient fetch failure for ${url}${statusPart} — failed after ${attempts} attempt${attempts === 1 ? "" : "s"}. ` + `This is typically a rate-limit or upstream outage. Retry later or check upstream status.`, ); this.name = "TransientFetchError"; } } // ── Fetch with cache ─────────────────────────────────────────────── const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours /** * Fetch a URL with local file caching. * * Returns cached data if the cache file exists and is younger than cacheTtlMs. * Otherwise downloads from the URL, caches the result, and returns it. */ export async function fetchWithCache(config: FetchConfig, force = false): Promise { const ttl = config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; if (!force) { try { if (existsSync(config.cacheFile)) { const stat = statSync(config.cacheFile); if (Date.now() - stat.mtimeMs < ttl) { return readFileSync(config.cacheFile) as unknown as Buffer; } } } catch (e) { debug("cache read failed:", e); } } const response = await fetchWithRetry(config.url); const arrayBuffer = await response.arrayBuffer(); const data = Buffer.from(arrayBuffer); try { mkdirSync(dirname(config.cacheFile), { recursive: true }); writeFileSync(config.cacheFile, data as unknown as Uint8Array); } catch (e) { debug("cache write failed:", e); } return data; } // ── Zip extraction ───────────────────────────────────────────────── /** * Extract files from a zip buffer using fflate. * * @param filter - Optional predicate to select which files to include. * Receives the file name (path within the zip). Defaults to all files. * @returns Map of filename → Buffer for each extracted file. */ export async function extractFromZip( zipData: Buffer, filter?: (name: string) => boolean, ): Promise> { const { unzipSync } = await import("fflate"); const files = unzipSync(new Uint8Array(zipData)); const result = new Map(); for (const [name, data] of Object.entries(files)) { if (filter && !filter(name)) continue; result.set(name, Buffer.from(data)); } return result; } // ── Tar extraction ────────────────────────────────────────────────── /** * Extract files from an uncompressed tar buffer. * * The caller handles gunzip (via `fflate.gunzipSync` or `zlib.gunzipSync`) * and any prefix stripping. Returns `Map`. * * @param filter - Optional predicate to select which files to include. * Receives the full file name (path within the tar). Defaults to all regular files. */ export function extractFromTar( tarData: Uint8Array, filter?: (path: string) => boolean, ): Map { const result = new Map(); let offset = 0; let longName: string | null = null; while (offset < tarData.length - 512) { const header = tarData.slice(offset, offset + 512); offset += 512; // Check for end-of-archive marker (all zeros) if (header.every((b) => b === 0)) break; // Parse file name (first 100 bytes) const nameBytes = header.slice(0, 100); let name = new TextDecoder().decode(nameBytes).replace(/\0+$/, ""); // Check type flag const typeFlag = String.fromCharCode(header[156]); // Parse file size (bytes 124-135, octal) const sizeStr = new TextDecoder().decode(header.slice(124, 136)).replace(/\0+$/, "").trim(); const size = parseInt(sizeStr, 8) || 0; // Calculate blocks to skip const blocks = Math.ceil(size / 512); if (typeFlag === "L") { // GNU long name: read the name from the next data block const longNameData = tarData.slice(offset, offset + size); longName = new TextDecoder().decode(longNameData).replace(/\0+$/, ""); offset += blocks * 512; continue; } // Apply long name from previous GNU 'L' entry if (longName !== null) { name = longName; longName = null; } else { // Check prefix field (bytes 345-500) for USTAR format const prefix = new TextDecoder().decode(header.slice(345, 500)).replace(/\0+$/, ""); if (prefix) { name = prefix + "/" + name; } } const fileData = tarData.slice(offset, offset + size); offset += blocks * 512; // Skip non-regular files if (typeFlag !== "0" && typeFlag !== "\0") continue; if (filter && !filter(name)) continue; result.set(name, Buffer.from(fileData)); } return result; } // ── Directory-level tar cache ─────────────────────────────────────── export interface FetchTarConfig { /** URL of the gzipped tarball. */ url: string; /** Local directory for extracted files. */ destDir: string; /** Cache TTL in milliseconds (default: 7 days). */ cacheTtlMs?: number; } const DEFAULT_TAR_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days /** * Fetch a gzipped tarball, extract files matching `tarPrefix`, and cache them * in `destDir`. Returns `destDir`. * * Checks `destDir` mtime against TTL. If fresh, returns immediately. * Otherwise downloads, gunzips, extracts matching files, and writes to `destDir`. * * @param tarPrefix - Only files whose path (after stripping the top-level directory) * starts with this prefix will be extracted. * @param force - If true, ignore cache and re-download. */ export async function fetchAndExtractTar( config: FetchTarConfig, tarPrefix: string, force = false, ): Promise { const ttl = config.cacheTtlMs ?? DEFAULT_TAR_CACHE_TTL_MS; if (!force) { try { if (existsSync(config.destDir)) { const stat = statSync(config.destDir); if (Date.now() - stat.mtimeMs < ttl) { return config.destDir; } } } catch (e) { debug("tar cache check failed:", e); } } const resp = await fetchWithRetry(config.url); const compressed = new Uint8Array(await resp.arrayBuffer()); const { gunzipSync } = await import("fflate"); const tarData = gunzipSync(compressed); // Remove old cache if (existsSync(config.destDir)) { rmSync(config.destDir, { recursive: true }); } // Extract files matching the prefix let extracted = 0; const files = extractFromTar(tarData); for (const [name, data] of files) { // Strip top-level directory (e.g. "cfn-lint-main/") const slashIdx = name.indexOf("/"); if (slashIdx < 0) continue; const relPath = name.slice(slashIdx + 1); if (!relPath.startsWith(tarPrefix)) continue; const localPath = relPath.slice(tarPrefix.length); if (!localPath) continue; const fullPath = join(config.destDir, localPath); const dir = dirname(fullPath); mkdirSync(dir, { recursive: true }); writeFileSync(fullPath, data as unknown as Uint8Array); extracted++; } if (extracted === 0) { throw new Error(`No files matching prefix "${tarPrefix}" found in tarball`); } return config.destDir; } // ── Cache utilities ──────────────────────────────────────────────── /** * Clear a cache file. Ignores errors if the file doesn't exist. */ export function clearCacheFile(cacheFile: string): void { try { if (existsSync(cacheFile)) unlinkSync(cacheFile); } catch (e) { debug("cache clear failed:", e); } }