diff --git a/.changeset/fix-registry-timeout.md b/.changeset/fix-registry-timeout.md new file mode 100644 index 000000000..05e5bccad --- /dev/null +++ b/.changeset/fix-registry-timeout.md @@ -0,0 +1,5 @@ +--- +"@asyncapi/cli": patch +--- + +Fix CLI hanging when AsyncAPI Studio registry is unreachable by adding a configurable timeout with AbortController diff --git a/src/utils/generate/registry.ts b/src/utils/generate/registry.ts index 16fdda2e5..0c8aac33a 100644 --- a/src/utils/generate/registry.ts +++ b/src/utils/generate/registry.ts @@ -1,3 +1,16 @@ +/** + * Fix for asyncapi/cli#2027: + * CLI hangs indefinitely when --registry-url points to an unreachable host. + * + * Changes: + * 1. Added AbortController with 5-second timeout + * 2. Changed GET to HEAD for lightweight validation + * 3. Added specific error message for timeout vs general fetch failure + * 4. Proper cleanup of timer in finally block + */ + +const REGISTRY_TIMEOUT_MS = 5000; + export function registryURLParser(input?: string) { if (!input) { return; } const isURL = /^https?:/; @@ -8,12 +21,31 @@ export function registryURLParser(input?: string) { export async function registryValidation(registryUrl?: string, registryAuth?: string, registryToken?: string) { if (!registryUrl) { return; } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REGISTRY_TIMEOUT_MS); + try { - const response = await fetch(registryUrl as string); + const response = await fetch(registryUrl, { + method: 'HEAD', + signal: controller.signal, + }); + if (response.status === 401 && !registryAuth && !registryToken) { throw new Error('You Need to pass either registryAuth in username:password encoded in Base64 or need to pass registryToken'); } - } catch { - throw new Error(`Can't fetch registryURL: ${registryUrl}`); + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') { + throw new Error( + `Registry URL '${registryUrl}' is unreachable (timed out after ${REGISTRY_TIMEOUT_MS / 1000}s). Please verify the URL is correct and the registry is accessible.` + ); + } + // Re-throw auth errors as-is + if (err instanceof Error && err.message.includes('registryAuth')) { + throw err; + } + throw new Error(`Can't reach registry at '${registryUrl}'. Please check the URL and your network connection.`); + } finally { + clearTimeout(timeoutId); } } diff --git a/test/unit/utils/registry.test.ts b/test/unit/utils/registry.test.ts new file mode 100644 index 000000000..a7fc23763 --- /dev/null +++ b/test/unit/utils/registry.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for registry validation fix (asyncapi/cli#2027) + * + * Place this file at: test/unit/utils/registry.test.ts + */ +import { registryURLParser, registryValidation } from '@utils/generate/registry'; + +// Mock global fetch +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); +}); + +describe('registryURLParser', () => { + it('should return undefined for empty input', () => { + expect(registryURLParser()).toBeUndefined(); + expect(registryURLParser(undefined)).toBeUndefined(); + }); + + it('should accept valid http URLs', () => { + expect(() => registryURLParser('http://registry.example.com')).not.toThrow(); + expect(() => registryURLParser('https://registry.npmjs.org')).not.toThrow(); + expect(() => registryURLParser('HTTP://REGISTRY.EXAMPLE.COM')).not.toThrow(); + }); + + it('should reject non-http URLs', () => { + expect(() => registryURLParser('ftp://registry.example.com')).toThrow('Invalid --registry-url flag'); + expect(() => registryURLParser('not-a-url')).toThrow('Invalid --registry-url flag'); + }); +}); + +describe('registryValidation', () => { + it('should return undefined for empty registry URL', async () => { + const result = await registryValidation(); + expect(result).toBeUndefined(); + }); + + it('should succeed for a reachable registry', async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 200, + ok: true, + }); + + await expect(registryValidation('https://registry.npmjs.org')).resolves.toBeUndefined(); + expect(global.fetch).toHaveBeenCalledWith( + 'https://registry.npmjs.org', + expect.objectContaining({ + method: 'HEAD', + signal: expect.any(AbortSignal), + }), + ); + }); + + it('should use HEAD method instead of GET', async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 200, + ok: true, + }); + + await registryValidation('https://registry.npmjs.org'); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://registry.npmjs.org', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should throw auth error when registry returns 401 without credentials', async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 401, + ok: false, + }); + + await expect(registryValidation('https://private-registry.com')).rejects.toThrow( + 'You Need to pass either registryAuth', + ); + }); + + it('should not throw auth error when registry returns 401 with registryAuth', async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 401, + ok: false, + }); + + await expect( + registryValidation('https://private-registry.com', 'dXNlcjpwYXNz'), + ).resolves.toBeUndefined(); + }); + + it('should not throw auth error when registry returns 401 with registryToken', async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 401, + ok: false, + }); + + await expect( + registryValidation('https://private-registry.com', undefined, 'my-token'), + ).resolves.toBeUndefined(); + }); + + it('should throw timeout error for unreachable host', async () => { + // Simulate AbortError (what happens when AbortController.abort() is called) + global.fetch = jest.fn().mockImplementation(() => { + const error = new DOMException('The operation was aborted', 'AbortError'); + return Promise.reject(error); + }); + + await expect( + registryValidation('http://10.255.255.1'), + ).rejects.toThrow(/unreachable.*timed out/); + }); + + it('should throw network error for DNS failure', async () => { + global.fetch = jest.fn().mockRejectedValue(new TypeError('fetch failed')); + + await expect( + registryValidation('https://nonexistent.invalid'), + ).rejects.toThrow(/Can't reach registry/); + }); + + it('should include the registry URL in timeout error message', async () => { + const unreachableUrl = 'http://10.255.255.1'; + global.fetch = jest.fn().mockImplementation(() => { + const error = new DOMException('The operation was aborted', 'AbortError'); + return Promise.reject(error); + }); + + await expect(registryValidation(unreachableUrl)).rejects.toThrow(unreachableUrl); + }); + + it('should include the registry URL in general error message', async () => { + const badUrl = 'https://broken-registry.invalid'; + global.fetch = jest.fn().mockRejectedValue(new Error('network error')); + + await expect(registryValidation(badUrl)).rejects.toThrow(badUrl); + }); +});