import { execFile as defaultExecFile } from 'node:child_process'; import type { FetchLike, FetchResponseLike } from './release-assets'; import type { CurlExecFile } from './curl-http-executor'; export interface ElectronNetFetchLike { fetch: (url: string, init?: Record) => Promise; } export type GlobalFetchLike = (url: string, init?: RequestInit) => Promise; export function createElectronNetFetch(net: ElectronNetFetchLike): FetchLike { return (url, init) => net.fetch(url, init); } function getGlobalFetch(): GlobalFetchLike { if (typeof globalThis.fetch !== 'function') { throw new Error('Global fetch is not available for updater requests.'); } return globalThis.fetch.bind(globalThis) as GlobalFetchLike; } export function createGlobalFetch(fetchImpl?: GlobalFetchLike): FetchLike { return (url, init) => (fetchImpl ?? getGlobalFetch())(url, init as RequestInit); } type CurlFetchOptions = { execFile?: CurlExecFile; curlPath?: string; }; function addHeaderArgs(args: string[], headers: unknown): void { if (!headers) return; if (Array.isArray(headers)) { for (const header of headers) { if (Array.isArray(header) && header.length >= 2) { args.push('--header', `${header[0]}: ${header[1]}`); } } return; } if (typeof headers === 'object' && 'forEach' in headers) { (headers as { forEach: (callback: (value: string, name: string) => void) => void }).forEach( (value, name) => { args.push('--header', `${name}: ${value}`); }, ); return; } if (typeof headers !== 'object') return; for (const [name, value] of Object.entries(headers as Record)) { if (value === undefined) continue; const values = Array.isArray(value) ? value : [value]; for (const item of values) { args.push('--header', `${name}: ${String(item)}`); } } } function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { const result = new ArrayBuffer(buffer.length); new Uint8Array(result).set(buffer); return result; } export function createCurlFetch(options: CurlFetchOptions = {}): FetchLike { const execFile = options.execFile ?? (defaultExecFile as unknown as CurlExecFile); const curlPath = options.curlPath ?? '/usr/bin/curl'; return async (url, init = {}) => { const args = [ '--fail', '--location', '--silent', '--show-error', '--connect-timeout', '30', '--max-time', '60', ]; addHeaderArgs(args, init.headers); args.push(url); // curl has its own --max-time, but a caller-supplied signal must be able to // cut the request short of it; otherwise a shorter caller deadline is a lie. const signal = init.signal instanceof AbortSignal ? init.signal : undefined; // Mirror fetch: reject with the caller's reason whatever its type, and only // synthesise an error when the signal carries none. const abortReason = (): unknown => signal?.reason !== undefined ? signal.reason : new Error('curl request aborted'); if (signal?.aborted) throw abortReason(); const body = await new Promise((resolve, reject) => { let onAbort: (() => void) | null = null; const settle = (run: () => void) => { if (onAbort) signal?.removeEventListener('abort', onAbort); onAbort = null; run(); }; const child = execFile( curlPath, args, { encoding: 'buffer', maxBuffer: 600 * 1024 * 1024, timeout: 65_000, }, (error, stdout, stderr) => { if (error) { const stderrMessage = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr; const errno = (error as NodeJS.ErrnoException).code; const fallback = errno ? `curl failed (${errno})` : 'curl failed'; settle(() => reject(new Error(stderrMessage.trim() || fallback))); return; } settle(() => resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout))); }, ); if (signal) { onAbort = () => { onAbort = null; child.kill('SIGKILL'); reject(abortReason()); }; signal.addEventListener('abort', onAbort, { once: true }); } }); return { ok: true, status: 200, statusText: 'OK', json: async () => JSON.parse(body.toString('utf8')), text: async () => body.toString('utf8'), arrayBuffer: async () => bufferToArrayBuffer(body), }; }; }