diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index 7c172d6d..efe77b0b 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -125,6 +125,12 @@ so a caller that needs a whole-app build must reject those itself. resolution the compiler itself performs. Install it in the worker or child process that runs a compile — not in a process that later needs the network. +`@deno/loader` downloads dependencies through the global `fetch` and never +retries, so the first compile in a process wraps `globalThis.fetch` once: GETs +to those same dependency hosts are retried on a network error, 429 or 5xx, and +each failure is logged as `base44.bundler.dependency_fetch_failed`. Every other +request passes through untouched. + ### Diagnostics The compiler ships no tracer and no service credentials. Hosts that want spans diff --git a/packages/functions-compiler/src/dependency-fetch-retry.ts b/packages/functions-compiler/src/dependency-fetch-retry.ts new file mode 100644 index 00000000..9a3c0248 --- /dev/null +++ b/packages/functions-compiler/src/dependency-fetch-retry.ts @@ -0,0 +1,76 @@ +/** + * Retries the dependency downloads `@deno/loader` makes through the global + * `fetch`. The loader never retries (its WASM HTTP client: "todo: implement + * retrying") and drops the failed download's error, so one registry blip deep + * in a large tree (Stagehand: ~350 downloads) fails the build as "Could not + * find constraint '' in the list of packages", or as a missing + * module when a tarball was lost. + * + * Only GETs to the dependency hosts are retried, on a network error, 429 or + * 5xx; a 404 is a real answer. Every other request — including the host's own + * API calls — passes straight through. + */ + +import { + type FetchInput, + isDependencyHost, + requestUrl, +} from "./fetch-guard.js"; +import { logEvent } from "./log.js"; + +const RETRY_DELAYS_MS = [250, 1000]; + +let installed = false; + +/** Wrap `globalThis.fetch` once, before the first loader is created. */ +export function installDependencyFetchRetry(): void { + if (installed) return; + installed = true; + globalThis.fetch = createRetryingFetch(globalThis.fetch.bind(globalThis)); +} + +export function createRetryingFetch( + originalFetch: typeof fetch, + delaysMs: readonly number[] = RETRY_DELAYS_MS, +): typeof fetch { + return (async (input: FetchInput, init?: RequestInit) => { + const url = requestUrl(input); + const method = + init?.method ?? (input instanceof Request ? input.method : "GET"); + if (method.toUpperCase() !== "GET" || !isDependencyHost(url.hostname)) { + return originalFetch(input, init); + } + + for (let attempt = 0; ; attempt++) { + const delay = delaysMs[attempt]; + let response: Response | undefined; + let error: unknown; + try { + response = await originalFetch(input, init); + if (response.status !== 429 && response.status < 500) return response; + } catch (err) { + error = err; + } + // The loader swallows this failure, so the log is the only place its + // real cause shows. + logEvent( + delay === undefined ? "error" : "warn", + "base44.bundler.dependency_fetch_failed", + { + url: url.href, + attempt: attempt + 1, + http_status: response?.status, + error: response + ? undefined + : String((error as Error)?.cause ?? error), + }, + ); + if (delay === undefined) { + if (response) return response; + throw error; + } + await response?.body?.cancel(); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + }) as typeof fetch; +} diff --git a/packages/functions-compiler/src/esbuild/deno-resolver.ts b/packages/functions-compiler/src/esbuild/deno-resolver.ts index 4807167f..1ba09360 100644 --- a/packages/functions-compiler/src/esbuild/deno-resolver.ts +++ b/packages/functions-compiler/src/esbuild/deno-resolver.ts @@ -36,6 +36,7 @@ import type { OnResolveResult, Plugin, } from "esbuild"; +import { installDependencyFetchRetry } from "../dependency-fetch-retry.js"; import { lazyDenoLoader } from "../lazy-deps.js"; import { logEvent } from "../log.js"; import { USER_NAMESPACE } from "./user-files.js"; @@ -61,6 +62,7 @@ export function denoResolverPlugin(options: DenoResolverOptions = {}): Plugin { return { name: "deno-resolver", async setup(build) { + installDependencyFetchRetry(); deno = await lazyDenoLoader(); // Security: stop a dependency or data: module from reading host files. // Allow file: reads only from where deps live — the global cache, or this diff --git a/packages/functions-compiler/src/fetch-guard.ts b/packages/functions-compiler/src/fetch-guard.ts index aabfadb9..58cb17cc 100644 --- a/packages/functions-compiler/src/fetch-guard.ts +++ b/packages/functions-compiler/src/fetch-guard.ts @@ -46,7 +46,7 @@ export function createGuardedFetch( // `preconnect`), and the guard wraps only the call. return (async (input: FetchInput, init?: RequestInit) => { const url = requestUrl(input); - if (!isAllowedHost(url.hostname)) { + if (!isDependencyHost(url.hostname)) { logEvent("warn", "base44.bundler.fetch_blocked", { host: url.hostname }); throw new Error( `Blocked fetch to disallowed host "${url.hostname}". Allowed: the npm and jsr registries and the esm.sh / deno.land CDNs — import dependencies via npm:, jsr:, or an https: URL on one of those hosts.`, @@ -71,15 +71,15 @@ export function createGuardedFetch( }) as typeof fetch; } -type FetchInput = Parameters[0]; +export type FetchInput = Parameters[0]; -function requestUrl(input: FetchInput): URL { +export function requestUrl(input: FetchInput): URL { if (input instanceof URL) return input; if (typeof input === "string") return new URL(input); return new URL(input.url); } -function isAllowedHost(hostname: string): boolean { +export function isDependencyHost(hostname: string): boolean { return ALLOWED_HOST_SUFFIXES.some( (suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`), ); diff --git a/packages/functions-compiler/test/dependency-fetch-retry.e2e.test.ts b/packages/functions-compiler/test/dependency-fetch-retry.e2e.test.ts new file mode 100644 index 00000000..9373938d --- /dev/null +++ b/packages/functions-compiler/test/dependency-fetch-retry.e2e.test.ts @@ -0,0 +1,55 @@ +/** + * One transient registry failure deep in a dependency tree must not fail the + * build. `@deno/loader` never retries, and reports the lost download as "Could + * not find constraint '' in the list of packages" — the Stagehand + * package-matrix row flaked on exactly that in CI. + */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterAll, beforeAll, expect, it } from "vitest"; + +import { bundle } from "../src/bundler"; + +// is-odd@3.0.1 depends on is-number; its metadata fails once. +const FLAKY_URL = "https://registry.npmjs.org/is-number"; + +let denoDir: string; +let realFetch: typeof fetch; +let flakyHits = 0; +beforeAll(() => { + // A cold cache, so the loader really downloads is-number. + denoDir = mkdtempSync(path.join(tmpdir(), "bundler-retry-")); + process.env.DENO_DIR = denoDir; + // Replaced before the first bundle, as a host's fetch would be. + realFetch = globalThis.fetch; + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + if (String(input) === FLAKY_URL && flakyHits++ === 0) { + return new Response("upstream unavailable", { status: 503 }); + } + return realFetch(input, init); + }) as typeof fetch; +}); +afterAll(() => { + globalThis.fetch = realFetch; + delete process.env.DENO_DIR; + rmSync(denoDir, { recursive: true, force: true }); +}); + +it("retries a dependency download that failed transiently", async () => { + const result = await bundle({ + entry: "main.ts", + files: { + "main.ts": `import isOdd from "npm:is-odd@3.0.1"; +Deno.serve(() => new Response(String(isOdd(3))));`, + }, + }); + + expect(result.ok ? [] : result.errors).toEqual([]); + expect(flakyHits).toBe(2); +}); diff --git a/packages/functions-compiler/test/dependency-fetch-retry.test.ts b/packages/functions-compiler/test/dependency-fetch-retry.test.ts new file mode 100644 index 00000000..ff17e1a3 --- /dev/null +++ b/packages/functions-compiler/test/dependency-fetch-retry.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createRetryingFetch } from "../src/dependency-fetch-retry"; + +const PACKUMENT = "https://registry.npmjs.org/zod"; + +const retryingOver = (original: () => Promise) => + createRetryingFetch(original as unknown as typeof fetch, [0, 0]); + +describe("dependency fetch retry", () => { + it("retries network errors and 5xx, then gives up with the last answer", async () => { + const original = vi + .fn() + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValue(new Response("", { status: 503 })); + + const res = await retryingOver(original)(PACKUMENT); + + expect(res.status).toBe(503); + expect(original).toHaveBeenCalledTimes(3); + }); + + it("leaves a 404 and the host's own requests alone", async () => { + const unavailable = vi.fn(async () => new Response("", { status: 503 })); + await retryingOver(unavailable)("https://app.base44.com/api/apps"); + await retryingOver(unavailable)(PACKUMENT, { method: "POST" }); + expect(unavailable).toHaveBeenCalledTimes(2); + + const missing = vi.fn(async () => new Response("", { status: 404 })); + await retryingOver(missing)(PACKUMENT); + expect(missing).toHaveBeenCalledTimes(1); + }); +});