#!/usr/bin/env node import { createHash } from "node:crypto"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { resolve } from "node:path"; import process from "node:process"; const CACHE_DIRECTORY = resolve(import.meta.dirname, ".cache"); const DEFAULT_TTL_SECONDS = 15 * 60; // 15 minutes export async function fetchCached(url) { await mkdir(CACHE_DIRECTORY, { recursive: true }); const cacheFile = resolve(CACHE_DIRECTORY, hashUrl(url) + ".json"); const cached = await loadCacheEntry(cacheFile); if (cached && cached.expires > Math.floor(Date.now() / 1000)) { return cached.data; } // Make request, with conditional If-None-Match if we have an ETag. // Cache-Control: max-age=0 overrides Node's default 'no-cache' to allow 304 responses. const response = await fetch(url, { headers: { "Cache-Control": "max-age=0", ...(cached?.etag && { "If-None-Match": cached.etag }), }, }); if (response.status === 304 && cached) { // Refresh expiration and return cached data const entry = { ...cached, expires: getExpires(response.headers) }; await saveCacheEntry(cacheFile, entry); return cached.data; } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const etag = response.headers.get("etag"); const data = await response.text(); const expires = getExpires(response.headers); await saveCacheEntry(cacheFile, { url, etag, expires, data }); return data; } function hashUrl(url) { return createHash("sha256").update(url).digest("hex").slice(0, 16); } async function loadCacheEntry(cacheFile) { try { return JSON.parse(await readFile(cacheFile, "utf-8")); } catch { return null; } } async function saveCacheEntry(cacheFile, entry) { await writeFile(cacheFile, JSON.stringify(entry, null, 2)); } function getExpires(headers) { const now = Math.floor(Date.now() / 1000); // Prefer Cache-Control: max-age const maxAgeSeconds = parseMaxAge(headers.get("cache-control")); if (maxAgeSeconds != null) { return now + maxAgeSeconds; } // Fall back to Expires header const expires = headers.get("expires"); if (expires) { const expiresTime = Date.parse(expires); if (!Number.isNaN(expiresTime)) { return Math.floor(expiresTime / 1000); } } // Default TTL return now + DEFAULT_TTL_SECONDS; } function parseMaxAge(cacheControl) { if (!cacheControl) { return null; } const match = cacheControl.match(/max-age=(\d+)/i); return match ? parseInt(match[1], 10) : null; } if (import.meta.main) { const url = process.argv[2]; if (!url || url === "--help" || url === "-h") { console.log(`Usage: fetch Fetches a URL with HTTP caching (ETags + Cache-Control/Expires). Default TTL: ${DEFAULT_TTL_SECONDS / 60} minutes. Cache is stored in: ${CACHE_DIRECTORY}/`); process.exit(url ? 0 : 1); } const data = await fetchCached(url); console.log(data); }