diff --git a/src/cli.ts b/src/cli.ts index 922840b..89b855d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import './lib/fetch-timeout'; import { Command } from '@commander-js/extra-typings'; import pc from 'picocolors'; import { apiKeysCommand } from './commands/api-keys/index'; diff --git a/src/lib/fetch-timeout.ts b/src/lib/fetch-timeout.ts new file mode 100644 index 0000000..8d957fb --- /dev/null +++ b/src/lib/fetch-timeout.ts @@ -0,0 +1,15 @@ +export const REQUEST_TIMEOUT_MS = 30_000; + +const originalFetch = globalThis.fetch; + +globalThis.fetch = ( + input: RequestInfo | URL, + init?: RequestInit, +): Promise => { + const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + const signal = init?.signal + ? AbortSignal.any([init.signal, timeoutSignal]) + : timeoutSignal; + + return originalFetch(input, { ...init, signal }); +}; diff --git a/tests/lib/fetch-timeout.test.ts b/tests/lib/fetch-timeout.test.ts new file mode 100644 index 0000000..b37f259 --- /dev/null +++ b/tests/lib/fetch-timeout.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('fetch-timeout', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.resetModules(); + globalThis.fetch = originalFetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('attaches a timeout signal when no signal is provided', async () => { + const fakeFetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + expect(init?.signal).toBeDefined(); + return Promise.resolve(new Response('ok')); + }); + globalThis.fetch = fakeFetch as typeof fetch; + + await import('../../src/lib/fetch-timeout'); + await globalThis.fetch('https://example.com'); + + expect(fakeFetch).toHaveBeenCalledOnce(); + }); + + it('preserves a caller-provided signal alongside the timeout', async () => { + const callerAbort = new AbortController(); + const fakeFetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + expect(init?.signal).toBeDefined(); + expect(init?.signal).not.toBe(callerAbort.signal); + return Promise.resolve(new Response('ok')); + }); + globalThis.fetch = fakeFetch as typeof fetch; + + await import('../../src/lib/fetch-timeout'); + await globalThis.fetch('https://example.com', { + signal: callerAbort.signal, + }); + + expect(fakeFetch).toHaveBeenCalledOnce(); + }); + + it('forwards all init options to the underlying fetch', async () => { + const fakeFetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + expect(init?.method).toBe('POST'); + expect(init?.headers).toEqual({ 'X-Test': '1' }); + return Promise.resolve(new Response('ok')); + }); + globalThis.fetch = fakeFetch as typeof fetch; + + await import('../../src/lib/fetch-timeout'); + await globalThis.fetch('https://example.com', { + method: 'POST', + headers: { 'X-Test': '1' }, + }); + + expect(fakeFetch).toHaveBeenCalledOnce(); + }); + + it('exports REQUEST_TIMEOUT_MS as 30000', async () => { + const mod = await import('../../src/lib/fetch-timeout'); + expect(mod.REQUEST_TIMEOUT_MS).toBe(30_000); + }); +});