From 54484aee8bacdcc28bd9273a86a41a605f5b8c2a Mon Sep 17 00:00:00 2001 From: Daniel Gaskins Date: Tue, 22 Sep 2026 06:32:56 -0700 Subject: [PATCH 1/2] fix: normalize cloud API URL before custom check --- src/__tests__/utils/config.test.ts | 23 ++++++++++++++++++++++- src/utils/config.ts | 2 +- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/__tests__/utils/config.test.ts b/src/__tests__/utils/config.test.ts index 6d2e83d4c2..05d329745a 100644 --- a/src/__tests__/utils/config.test.ts +++ b/src/__tests__/utils/config.test.ts @@ -11,7 +11,7 @@ import { validateConfig, isCustomApiUrl, } from '../../utils/config'; -import { getClient, resetClient } from '../../utils/client'; +import { getClient, isKeylessMode, resetClient } from '../../utils/client'; import * as credentials from '../../utils/credentials'; // Mock credentials module @@ -240,6 +240,12 @@ describe('Config Fallback Priority', () => { expect(isCustomApiUrl()).toBe(false); }); + it('should ignore trailing slashes on the default cloud API URL', () => { + initializeConfig({ apiUrl: 'https://api.firecrawl.dev/' }); + expect(isCustomApiUrl()).toBe(false); + expect(isCustomApiUrl('https://api.firecrawl.dev///')).toBe(false); + }); + it('should return true for custom API URLs', () => { initializeConfig({ apiUrl: 'http://localhost:3002' }); expect(isCustomApiUrl()).toBe(true); @@ -254,6 +260,16 @@ describe('Config Fallback Priority', () => { initializeConfig({ apiUrl: 'https://api.firecrawl.dev' }); expect(isCustomApiUrl('http://localhost:3002')).toBe(true); }); + + it('should keep custom API URLs custom when they have trailing slashes', () => { + expect(isCustomApiUrl('http://localhost:3002/')).toBe(true); + }); + }); + + describe('keyless mode with cloud API URLs', () => { + it('should allow a trailing slash on the default cloud API URL', () => { + expect(isKeylessMode(undefined, 'https://api.firecrawl.dev/')).toBe(true); + }); }); describe('validateConfig with custom API URLs', () => { @@ -268,6 +284,11 @@ describe('Config Fallback Priority', () => { expect(() => validateConfig()).toThrow('API key is required'); }); + it('should require API key for cloud API URL with a trailing slash', () => { + initializeConfig({ apiUrl: 'https://api.firecrawl.dev/' }); + expect(() => validateConfig()).toThrow('API key is required'); + }); + it('should not throw when API key is provided for cloud API', () => { initializeConfig({ apiUrl: 'https://api.firecrawl.dev', diff --git a/src/utils/config.ts b/src/utils/config.ts index 1374a308fc..69a046bd3e 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -79,7 +79,7 @@ const DEFAULT_API_URL = 'https://api.firecrawl.dev'; */ export function isCustomApiUrl(apiUrl?: string): boolean { const url = apiUrl || globalConfig.apiUrl; - return !!url && url !== DEFAULT_API_URL; + return !!url && url.replace(/\/+$/, '') !== DEFAULT_API_URL; } /** From 0782f177b5a38f8052e07c43445b37934fdf179e Mon Sep 17 00:00:00 2001 From: Daniel Gaskins Date: Tue, 22 Sep 2026 19:56:57 -0700 Subject: [PATCH 2/2] fix: normalize keyless API request URLs --- src/__tests__/commands/parse.test.ts | 2 +- src/__tests__/utils/client.test.ts | 45 ++++++++++++++++++++++++++++ src/commands/interact.ts | 4 +-- src/commands/parse.ts | 7 ++--- src/utils/client.ts | 5 ++-- src/utils/config.ts | 7 ++++- 6 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/utils/client.test.ts diff --git a/src/__tests__/commands/parse.test.ts b/src/__tests__/commands/parse.test.ts index 18b126d664..845278e3e6 100644 --- a/src/__tests__/commands/parse.test.ts +++ b/src/__tests__/commands/parse.test.ts @@ -45,7 +45,7 @@ describe('executeParse', () => { }); it('posts to /v2/parse without auth when using the default cloud API with no key', async () => { - initializeConfig({ apiUrl: 'https://api.firecrawl.dev' }); + initializeConfig({ apiUrl: 'https://api.firecrawl.dev///' }); const result = await executeParse({ file: filePath }); diff --git a/src/__tests__/utils/client.test.ts b/src/__tests__/utils/client.test.ts new file mode 100644 index 0000000000..d20a214399 --- /dev/null +++ b/src/__tests__/utils/client.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { keylessGet, keylessRequest } from '../../utils/client'; +import { initializeConfig, resetConfig } from '../../utils/config'; + +vi.mock('../../utils/credentials', () => ({ + loadCredentials: vi.fn(() => null), +})); + +describe('keyless requests', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + resetConfig(); + initializeConfig({ apiUrl: 'https://api.firecrawl.dev///' }); + fetchMock.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ success: true }), + }); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + resetConfig(); + }); + + it('joins POST paths after every trailing slash is removed', async () => { + await keylessRequest('/v2/scrape', { url: 'https://example.com' }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.firecrawl.dev/v2/scrape', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('joins GET paths after every trailing slash is removed', async () => { + await keylessGet('/v2/search/research'); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.firecrawl.dev/v2/search/research', + expect.objectContaining({ method: 'GET' }) + ); + }); +}); diff --git a/src/commands/interact.ts b/src/commands/interact.ts index e695e5d86c..3eda8149ea 100644 --- a/src/commands/interact.ts +++ b/src/commands/interact.ts @@ -4,7 +4,7 @@ */ import { getClient, isKeylessMode } from '../utils/client'; -import { getConfig, validateConfig } from '../utils/config'; +import { getConfig, normalizeApiUrl, validateConfig } from '../utils/config'; import { getScrapeId, loadInteractSession, @@ -46,7 +46,7 @@ function resolveApiConfig(options: { apiKey?: string; apiUrl?: string }) { validateConfig(apiKey); } const apiUrl = options.apiUrl || config.apiUrl || 'https://api.firecrawl.dev'; - return { apiKey, apiUrl: apiUrl.replace(/\/$/, ''), keyless }; + return { apiKey, apiUrl: normalizeApiUrl(apiUrl), keyless }; } /** diff --git a/src/commands/parse.ts b/src/commands/parse.ts index 4aa832fd9f..f24f8672cf 100644 --- a/src/commands/parse.ts +++ b/src/commands/parse.ts @@ -12,7 +12,7 @@ import type { FormatOption } from 'firecrawl'; import type { ParseOptions, ParseResult } from '../types/parse'; import type { ScrapeFormat } from '../types/scrape'; import { getClient, isKeylessMode } from '../utils/client'; -import { getConfig, validateConfig } from '../utils/config'; +import { getConfig, normalizeApiUrl, validateConfig } from '../utils/config'; import { handleScrapeOutput } from '../utils/output'; const DEFAULT_API_URL = 'https://api.firecrawl.dev'; @@ -162,9 +162,8 @@ export async function executeParse( validateConfig(apiKey); } - const apiUrl = (options.apiUrl || config.apiUrl || DEFAULT_API_URL).replace( - /\/$/, - '' + const apiUrl = normalizeApiUrl( + options.apiUrl || config.apiUrl || DEFAULT_API_URL ); const buffer = fs.readFileSync(filePath); diff --git a/src/utils/client.ts b/src/utils/client.ts index 6519495bfc..ecc7b00f27 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -11,6 +11,7 @@ import { isCustomApiUrl, validateConfig, updateConfig, + normalizeApiUrl, type GlobalConfig, } from './config'; @@ -33,7 +34,7 @@ export async function keylessRequest( path: string, body: Record ): Promise { - const apiUrl = (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); + const apiUrl = normalizeApiUrl(getConfig().apiUrl || DEFAULT_API_URL); const response = await fetch(`${apiUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -49,7 +50,7 @@ export async function keylessRequest( } export async function keylessGet(path: string): Promise { - const apiUrl = (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); + const apiUrl = normalizeApiUrl(getConfig().apiUrl || DEFAULT_API_URL); const response = await fetch(`${apiUrl}${path}`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, diff --git a/src/utils/config.ts b/src/utils/config.ts index 69a046bd3e..65e8c6bd32 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -74,12 +74,17 @@ export function getApiKey(providedKey?: string): string | undefined { const DEFAULT_API_URL = 'https://api.firecrawl.dev'; +/** Remove trailing slashes before comparing or joining an API base URL. */ +export function normalizeApiUrl(apiUrl: string): string { + return apiUrl.replace(/\/+$/, ''); +} + /** * Check if using a custom (non-cloud) API URL */ export function isCustomApiUrl(apiUrl?: string): boolean { const url = apiUrl || globalConfig.apiUrl; - return !!url && url.replace(/\/+$/, '') !== DEFAULT_API_URL; + return !!url && normalizeApiUrl(url) !== DEFAULT_API_URL; } /**