Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions packages/functions-compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions packages/functions-compiler/src/dependency-fetch-retry.ts
Original file line number Diff line number Diff line change
@@ -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 '<root package>' 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;
}
2 changes: 2 additions & 0 deletions packages/functions-compiler/src/esbuild/deno-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions packages/functions-compiler/src/fetch-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
Expand All @@ -71,15 +71,15 @@ export function createGuardedFetch(
}) as typeof fetch;
}

type FetchInput = Parameters<typeof fetch>[0];
export type FetchInput = Parameters<typeof fetch>[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}`),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 '<root package>' 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);
});
33 changes: 33 additions & 0 deletions packages/functions-compiler/test/dependency-fetch-retry.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response>) =>
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);
});
});
Loading