Skip to content

Commit 0e829ff

Browse files
committed
feat(webfetch): support self-hosted FireCrawl instances
Adds FIRECRAWL_BASE_URL env override + getFirecrawlBaseUrl() with cloud-vs-self-hosted detection (API key optional off-cloud). Re-cut onto release/v3.8.44 tip (branch was stale from a pre-v3.8.40 snapshot).
1 parent 3a3d618 commit 0e829ff

2 files changed

Lines changed: 191 additions & 9 deletions

File tree

open-sse/executors/firecrawl-fetch.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,37 @@
66
*
77
* Free tier: 500 fetches/month, no credit card required.
88
* Docs: https://docs.firecrawl.dev/api-reference/endpoint/scrape
9+
*
10+
* Self-hosted: set FIRECRAWL_BASE_URL to point at a self-hosted Firecrawl
11+
* instance (e.g. http://127.0.0.1:3002). The API key is only required against
12+
* the default cloud base URL — self-hosted instances typically run with no
13+
* auth in front of them, so credentials.apiKey becomes optional in that case.
914
*/
1015

1116
import { sanitizeErrorMessage, buildErrorBody } from "../utils/error.ts";
1217
import type { WebFetchResult, WebFetchFormat, WebFetchCredentials } from "../handlers/webFetch.ts";
1318

14-
const FIRECRAWL_API_BASE = "https://api.firecrawl.dev/v1";
15-
const FIRECRAWL_TIMEOUT_MS = 30_000;
19+
const FIRECRAWL_DEFAULT_BASE_URL = "https://api.firecrawl.dev";
20+
const FIRECRAWL_DEFAULT_TIMEOUT_MS = 30_000;
21+
22+
/** Resolve the configured Firecrawl base URL, falling back to the public cloud API. */
23+
function getFirecrawlBaseUrl(): string {
24+
const envBase = process.env.FIRECRAWL_BASE_URL?.trim();
25+
return envBase ? envBase.replace(/\/+$/, "") : FIRECRAWL_DEFAULT_BASE_URL;
26+
}
27+
28+
/** Whether the given base URL is the default Firecrawl cloud endpoint. */
29+
function isDefaultFirecrawlBaseUrl(baseUrl: string): boolean {
30+
return baseUrl === FIRECRAWL_DEFAULT_BASE_URL;
31+
}
32+
33+
/** Resolve the configured request timeout, falling back to a sane default. */
34+
function getFirecrawlTimeoutMs(): number {
35+
const raw = process.env.FIRECRAWL_TIMEOUT_MS;
36+
if (!raw) return FIRECRAWL_DEFAULT_TIMEOUT_MS;
37+
const parsed = Number(raw);
38+
return Number.isFinite(parsed) && parsed > 0 ? parsed : FIRECRAWL_DEFAULT_TIMEOUT_MS;
39+
}
1640

1741
function mapFormat(format: WebFetchFormat): string {
1842
switch (format) {
@@ -43,7 +67,12 @@ interface FirecrawlScrapeOptions {
4367
export async function firecrawlFetch(opts: FirecrawlScrapeOptions): Promise<WebFetchResult> {
4468
const { url, format, depth, waitForSelector, includeMetadata, credentials } = opts;
4569

46-
if (!credentials.apiKey) {
70+
const baseUrl = getFirecrawlBaseUrl();
71+
const isDefaultBaseUrl = isDefaultFirecrawlBaseUrl(baseUrl);
72+
73+
// The API key is mandatory for the public Firecrawl cloud API, but optional
74+
// once a custom (self-hosted) base URL is configured.
75+
if (isDefaultBaseUrl && !credentials.apiKey) {
4776
const body = buildErrorBody(401, "Firecrawl API key required");
4877
return { success: false, status: 401, error: body.error.message };
4978
}
@@ -71,15 +100,17 @@ export async function firecrawlFetch(opts: FirecrawlScrapeOptions): Promise<WebF
71100
}
72101

73102
const controller = new AbortController();
74-
const timeoutId = setTimeout(() => controller.abort(), FIRECRAWL_TIMEOUT_MS);
103+
const timeoutId = setTimeout(() => controller.abort(), getFirecrawlTimeoutMs());
75104

76105
try {
77-
const response = await fetch(`${FIRECRAWL_API_BASE}/scrape`, {
106+
const headers: Record<string, string> = { "Content-Type": "application/json" };
107+
if (credentials.apiKey) {
108+
headers.Authorization = `Bearer ${credentials.apiKey}`;
109+
}
110+
111+
const response = await fetch(`${baseUrl}/v1/scrape`, {
78112
method: "POST",
79-
headers: {
80-
"Content-Type": "application/json",
81-
Authorization: `Bearer ${credentials.apiKey}`,
82-
},
113+
headers,
83114
body: JSON.stringify(requestBody),
84115
signal: controller.signal,
85116
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
const { firecrawlFetch } = await import("../../../open-sse/executors/firecrawl-fetch.ts");
5+
6+
// ── #2253 (decolua/9router): self-hosted Firecrawl support ────────────────────
7+
// FIRECRAWL_BASE_URL lets operators point the executor at a self-hosted instance.
8+
// The API key stays required for the default cloud endpoint, but becomes optional
9+
// once a custom base URL is configured (self-hosted instances usually run with no
10+
// auth in front of them).
11+
12+
function withEnv(vars: Record<string, string | undefined>, fn: () => Promise<void>) {
13+
const original: Record<string, string | undefined> = {};
14+
for (const key of Object.keys(vars)) {
15+
original[key] = process.env[key];
16+
}
17+
for (const [key, value] of Object.entries(vars)) {
18+
if (value === undefined) delete process.env[key];
19+
else process.env[key] = value;
20+
}
21+
return fn().finally(() => {
22+
for (const [key, value] of Object.entries(original)) {
23+
if (value === undefined) delete process.env[key];
24+
else process.env[key] = value;
25+
}
26+
});
27+
}
28+
29+
test("firecrawlFetch routes to FIRECRAWL_BASE_URL when set", async () => {
30+
await withEnv({ FIRECRAWL_BASE_URL: "http://127.0.0.1:3002" }, async () => {
31+
const originalFetch = globalThis.fetch;
32+
let capturedUrl = "";
33+
34+
globalThis.fetch = async (url) => {
35+
capturedUrl = String(url);
36+
return new Response(JSON.stringify({ data: { markdown: "# Self-hosted" } }), {
37+
status: 200,
38+
headers: { "content-type": "application/json" },
39+
});
40+
};
41+
42+
try {
43+
const result = await firecrawlFetch({
44+
url: "https://example.com",
45+
format: "markdown",
46+
depth: 0,
47+
includeMetadata: false,
48+
credentials: { apiKey: "any-key" },
49+
});
50+
51+
assert.equal(result.success, true);
52+
assert.equal(capturedUrl, "http://127.0.0.1:3002/v1/scrape");
53+
} finally {
54+
globalThis.fetch = originalFetch;
55+
}
56+
});
57+
});
58+
59+
test("firecrawlFetch allows missing apiKey when FIRECRAWL_BASE_URL is custom", async () => {
60+
await withEnv({ FIRECRAWL_BASE_URL: "http://127.0.0.1:3002" }, async () => {
61+
const originalFetch = globalThis.fetch;
62+
let capturedHeaders: Record<string, string> = {};
63+
64+
globalThis.fetch = async (_url, init = {}) => {
65+
capturedHeaders = (init as RequestInit).headers as Record<string, string>;
66+
return new Response(JSON.stringify({ data: { markdown: "# Self-hosted" } }), {
67+
status: 200,
68+
headers: { "content-type": "application/json" },
69+
});
70+
};
71+
72+
try {
73+
const result = await firecrawlFetch({
74+
url: "https://example.com",
75+
format: "markdown",
76+
depth: 0,
77+
includeMetadata: false,
78+
credentials: {},
79+
});
80+
81+
assert.equal(result.success, true, "custom base URL must not require an API key");
82+
assert.equal(
83+
capturedHeaders["Authorization"],
84+
undefined,
85+
"no Authorization header should be sent without an apiKey"
86+
);
87+
} finally {
88+
globalThis.fetch = originalFetch;
89+
}
90+
});
91+
});
92+
93+
test("firecrawlFetch still requires apiKey against the default cloud base URL", async () => {
94+
await withEnv({ FIRECRAWL_BASE_URL: undefined }, async () => {
95+
const result = await firecrawlFetch({
96+
url: "https://example.com",
97+
format: "markdown",
98+
depth: 0,
99+
includeMetadata: false,
100+
credentials: {},
101+
});
102+
103+
assert.equal(result.success, false);
104+
assert.equal(result.status, 401);
105+
assert.ok(!result.error?.includes("at /"), "error must not contain stack trace");
106+
});
107+
});
108+
109+
test("firecrawlFetch honors FIRECRAWL_TIMEOUT_MS override", async () => {
110+
await withEnv(
111+
{ FIRECRAWL_BASE_URL: "http://127.0.0.1:3002", FIRECRAWL_TIMEOUT_MS: "5" },
112+
async () => {
113+
const originalFetch = globalThis.fetch;
114+
115+
globalThis.fetch = async (_url, init = {}) => {
116+
const signal = (init as RequestInit).signal;
117+
return new Promise((resolve, reject) => {
118+
const timer = setTimeout(
119+
() =>
120+
resolve(
121+
new Response(JSON.stringify({ data: { markdown: "late" } }), {
122+
status: 200,
123+
headers: { "content-type": "application/json" },
124+
})
125+
),
126+
50
127+
);
128+
signal?.addEventListener("abort", () => {
129+
clearTimeout(timer);
130+
reject(new DOMException("Aborted", "AbortError"));
131+
});
132+
});
133+
};
134+
135+
try {
136+
const result = await firecrawlFetch({
137+
url: "https://example.com",
138+
format: "markdown",
139+
depth: 0,
140+
includeMetadata: false,
141+
credentials: {},
142+
});
143+
144+
assert.equal(result.success, false);
145+
assert.equal(result.status, 504);
146+
} finally {
147+
globalThis.fetch = originalFetch;
148+
}
149+
}
150+
);
151+
});

0 commit comments

Comments
 (0)