/** * A `fetch`-compatible function with NO headers timeout, built on `node:https` * / `node:http`. * * WHY THIS EXISTS (live-verified 2026-09-16, the 91-minute cricket run): * Node's global `fetch` is undici, which enforces a ~300 s *headers* timeout and * aborts with `HeadersTimeoutError` — even though the server is still working * and will answer. A Gemini Interactions agentic call over a 15-minute video * segment takes 30–270 s, and a whole-match call takes longer than the cap. The * Interactions API is synchronous-only for Files API uploads (`background: true` * plus a Files `uri` dies server-side with "Unsupported file uri: blobstore://…"), * so there is no poll-later escape hatch: the client simply has to wait. * * `node:https.request` has no headers timeout at all, so this wraps it back into * the `fetch` shape that `createInteraction` / `uploadGeminiFile` already accept * through their `fetcher` option. No npm dependency is added (`undici` as a * package is deliberately NOT introduced). * * Deliberately narrow: enough of `fetch` for the two Gemini call sites (method, * headers, a string/Buffer body, an `AbortSignal`, a buffered response). It is * not a general-purpose fetch polyfill — no redirects, no streaming request * bodies, no cookie jar. */ import { request as httpRequest, type ClientRequest, type IncomingMessage } from 'node:http'; import { request as httpsRequest } from 'node:https'; // `RequestInit`, `Response`, `Headers` and `fetch` are global under // lib ES2022 + @types/node, but `RequestInfo` / `HeadersInit` / `BodyInit` are // not. Derive them from the globals rather than depending on undici-types. type FetchInput = Parameters[0]; type FetchHeadersInit = NonNullable; type FetchBodyInit = NonNullable; /** * Total-duration cap for the shared {@link longFetch}, in milliseconds. * * The reference harness used `curl --max-time 2400`, and that is the number * this matches. Undici's ~300 s headers cap is what this module exists to * escape, but escaping it entirely would park the CLI forever on a dropped * connection, so the cap is raised rather than removed. */ export const DEFAULT_LONG_FETCH_TIMEOUT_MS = 2_400_000; export interface LongFetchOptions { /** * **Total** request duration in milliseconds, measured from the moment the * request is sent until the response body has been read — not an inactivity * timeout. Exceeding it destroys the request. * * 0 disables the cap entirely. {@link createLongFetch} defaults to 0 so a * caller that wants no cap can have one; the shared {@link longFetch} instance * uses {@link DEFAULT_LONG_FETCH_TIMEOUT_MS}. */ timeoutMs?: number; } /** Response statuses that must carry a null body or `new Response` throws. */ const NULL_BODY_STATUS = new Set([204, 205, 304]); function normalizeHeaders(init: FetchHeadersInit | undefined): Record { const out: Record = {}; if (!init) return out; const append = (name: string, value: string): void => { const key = name.toLowerCase(); const existing = out[key]; if (existing === undefined) out[key] = value; else if (Array.isArray(existing)) existing.push(value); else out[key] = [existing, value]; }; if (init instanceof Headers) { init.forEach((value, name) => append(name, value)); } else if (Array.isArray(init)) { for (const [name, value] of init) append(name, value); } else { for (const [name, value] of Object.entries(init)) { if (value !== undefined) append(name, String(value)); } } return out; } /** `fetch` accepts far more; these are the body shapes the Gemini call sites use. */ function normalizeBody(body: FetchBodyInit | null | undefined): Buffer | undefined { if (body === null || body === undefined) return undefined; if (typeof body === 'string') return Buffer.from(body, 'utf-8'); if (Buffer.isBuffer(body)) return body; if (body instanceof Uint8Array) return Buffer.from(body); if (body instanceof ArrayBuffer) return Buffer.from(new Uint8Array(body)); throw new TypeError( `longFetch only accepts a string, Buffer, Uint8Array or ArrayBuffer body (got ${Object.prototype.toString.call(body)}).`, ); } function responseHeaders(message: IncomingMessage): Headers { const headers = new Headers(); for (const [name, value] of Object.entries(message.headers)) { if (value === undefined) continue; const values = Array.isArray(value) ? value : [value]; for (const single of values) { // A malformed header name from the wire must not take down the response. try { headers.append(name, single); } catch { /* ignore an unrepresentable header rather than failing the call */ } } } return headers; } /** * Build a `fetch`-compatible function that never times out waiting for response * headers. Pass the result as the `fetcher` option of `createInteraction` / * `uploadGeminiFile`. */ export function createLongFetch(options: LongFetchOptions = {}): typeof fetch { const timeoutMs = options.timeoutMs ?? 0; const longFetchImpl = async (input: FetchInput, init: RequestInit = {}): Promise => { const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url); if (url.protocol !== 'http:' && url.protocol !== 'https:') { throw new TypeError(`longFetch only supports http:/https: URLs (got ${url.protocol}).`); } const transport = url.protocol === 'https:' ? httpsRequest : httpRequest; const body = normalizeBody(init.body ?? null); const headers = normalizeHeaders(init.headers); if (body !== undefined && headers['content-length'] === undefined) { headers['content-length'] = String(body.length); } const signal = init.signal ?? undefined; if (signal?.aborted) throw signal.reason ?? new Error('longFetch request was aborted before it started.'); return await new Promise((resolve, reject) => { let settled = false; let timer: NodeJS.Timeout | undefined; let onAbort: (() => void) | undefined; const cleanup = (): void => { if (timer) clearTimeout(timer); timer = undefined; if (onAbort && signal) signal.removeEventListener('abort', onAbort); onAbort = undefined; }; const fail = (error: unknown): void => { if (settled) return; settled = true; cleanup(); reject(error); }; const succeed = (response: Response): void => { if (settled) return; settled = true; cleanup(); resolve(response); }; let req: ClientRequest; try { req = transport( { protocol: url.protocol, hostname: url.hostname, ...(url.port ? { port: Number(url.port) } : {}), path: `${url.pathname}${url.search}`, method: (init.method ?? 'GET').toUpperCase(), headers, }, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('error', fail); res.on('end', () => { const status = res.statusCode ?? 502; const payload = NULL_BODY_STATUS.has(status) ? null : Buffer.concat(chunks); try { succeed( new Response(payload, { status, ...(res.statusMessage ? { statusText: res.statusMessage } : {}), headers: responseHeaders(res), }), ); } catch (error) { fail(error); } }); }, ); } catch (error) { fail(error); return; } req.on('error', fail); // The knob this module exists to widen: 0 installs no timer at all, and // any positive value caps the WHOLE request (the timer is cleared only // once the body has been read), where undici would abort at ~300 s // waiting for headers alone. if (timeoutMs > 0) { timer = setTimeout(() => { req.destroy(new Error(`longFetch timed out after ${timeoutMs}ms on ${url.href}`)); }, timeoutMs); } if (signal) { onAbort = (): void => { req.destroy(signal.reason instanceof Error ? signal.reason : new Error('longFetch request was aborted.')); }; signal.addEventListener('abort', onAbort, { once: true }); } if (body !== undefined) req.write(body); req.end(); }); }; return longFetchImpl as typeof fetch; } /** * The shared fetcher the long-video commands pass to Gemini: no headers * timeout, and a 40-minute total cap so a dropped connection cannot park the * CLI forever. */ export const longFetch: typeof fetch = createLongFetch({ timeoutMs: DEFAULT_LONG_FETCH_TIMEOUT_MS });