Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions src/utils/generate/registry.ts
Original file line number Diff line number Diff line change
@@ -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?:/;
Expand All @@ -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);
}
}
139 changes: 139 additions & 0 deletions test/unit/utils/registry.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading