diff --git a/.changeset/fuzzy-bears-listen.md b/.changeset/fuzzy-bears-listen.md new file mode 100644 index 00000000000..adedf4999e2 --- /dev/null +++ b/.changeset/fuzzy-bears-listen.md @@ -0,0 +1,9 @@ +--- +"wrangler": patch +"@cloudflare/vite-plugin": patch +"@cloudflare/vitest-plugin": patch +--- + +Fail with an error instead of hanging when local dev requests can't work under Bun + +Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes requests through (oven-sh/bun#39247). Under Bun, `createTestHarness()`'s `server.fetch()` and `server.getWorker()`'s `fetch()`, `email()` and `scheduled()` now throw a `UserError`, requests to the dev server URL get a 503 naming the cause, and starting remote bindings throws a `UserError`. Each of these used to hang with no error. `server.getWorker().getEnv()` and `getExport()` keep working under Bun. diff --git a/packages/remote-bindings/src/start-remote-proxy-session.test.ts b/packages/remote-bindings/src/start-remote-proxy-session.test.ts index bf954d21727..d3d0cfa247e 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.test.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.test.ts @@ -57,6 +57,30 @@ function makeErrorEvent(cause: ErrorEvent["cause"]): ErrorEvent { } describe("startRemoteProxySession error handling", () => { + it("throws a UserError under Bun before starting the session", async ({ + expect, + onTestFinished, + }) => { + const originalBunVersion = process.versions.bun; + process.versions.bun = "1.4.4"; + onTestFinished(() => { + process.versions.bun = originalBunVersion; + }); + + const thrown = await startRemoteProxySession( + {}, + { cliDisplayName: "Test CLI", logger: createTestLogger() } + ).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(UserError); + expect(thrown).toMatchObject({ + message: expect.stringContaining( + "https://github.com/oven-sh/bun/issues/39247" + ), + telemetryMessage: "remote proxy session under bun", + }); + }); + // Regression: a `UserError` emitted asynchronously (e.g. the Miniflare // user-error case in `DevEnv.handleErrorEvent`) used to fall through to being // wrapped in a generic `Error`, which makes Wrangler treat it as an diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index e9001a7887c..08e1fb869dd 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -104,6 +104,16 @@ export async function startRemoteProxySession( } } + // The session hands its local proxy the remote connection in a control + // message sent through an undici `dispatcher`, which Bun's `fetch()` ignores, + // so every remote binding request would wait forever. + if (process.versions.bun) { + throw new UserError( + "Remote bindings can't work under Bun, because Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes them through (https://github.com/oven-sh/bun/issues/39247). Run this using Node.js, or turn remote bindings off.", + { telemetryMessage: "remote proxy session under bun" } + ); + } + options.logger.log(chalk.dim("⎔ Establishing remote connection...")); initLogger(getInternalLogger(options.logger)); const rawBindings = toRawBindings(bindings); diff --git a/packages/wrangler/src/__tests__/api/test-harness.test.ts b/packages/wrangler/src/__tests__/api/test-harness.test.ts new file mode 100644 index 00000000000..aaade3e667b --- /dev/null +++ b/packages/wrangler/src/__tests__/api/test-harness.test.ts @@ -0,0 +1,66 @@ +import { writeFileSync } from "node:fs"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { fetch } from "undici"; +import { describe, it, vi } from "vitest"; +import { createTestHarness } from "../../api/test-harness"; +import { BUN_UNSUPPORTED_MESSAGE } from "../../utils/is-bun"; +import { mockConsoleMethods } from "../helpers/mock-console"; + +const { isBunMock } = vi.hoisted(() => ({ isBunMock: vi.fn(() => false) })); +vi.mock("../../utils/is-bun", async (importOriginal) => ({ + ...(await importOriginal()), + isBun: isBunMock, +})); + +describe("createTestHarness under Bun", () => { + mockConsoleMethods(); + runInTempDir(); + + it("keeps getEnv() working and fails every dispatch with the Bun error", async ({ + expect, + onTestFinished, + }) => { + isBunMock.mockReturnValue(true); + onTestFinished(() => { + isBunMock.mockReturnValue(false); + }); + + writeFileSync( + "worker.js", + `export default { fetch: () => new Response("ok") };` + ); + writeFileSync( + "wrangler.jsonc", + JSON.stringify({ + name: "harness-under-bun", + main: "worker.js", + compatibility_date: "2026-05-20", + vars: { PROBE: "yes" }, + }) + ); + + const server = createTestHarness({ + workers: [{ configPath: "./wrangler.jsonc" }], + }); + onTestFinished(() => server.close()); + + const { url } = await server.listen(); + + const env = await server.getWorker<{ PROBE: string }>().getEnv(); + expect(env.PROBE).toBe("yes"); + + const refused = { + message: expect.stringContaining(BUN_UNSUPPORTED_MESSAGE), + telemetryMessage: "test harness dispatch under bun", + }; + await expect(server.fetch("/")).rejects.toMatchObject(refused); + await expect(server.getWorker().fetch("/")).rejects.toMatchObject(refused); + await expect( + server.getWorker().scheduled({ cron: "* * * * *" }) + ).rejects.toMatchObject(refused); + + const viaUrl = await fetch(url); + expect(viaUrl.status).toBe(503); + expect(await viaUrl.text()).toBe(BUN_UNSUPPORTED_MESSAGE); + }); +}); diff --git a/packages/wrangler/src/__tests__/utils/is-bun.test.ts b/packages/wrangler/src/__tests__/utils/is-bun.test.ts new file mode 100644 index 00000000000..6eea4df2ff5 --- /dev/null +++ b/packages/wrangler/src/__tests__/utils/is-bun.test.ts @@ -0,0 +1,18 @@ +import { describe, it } from "vitest"; +import { isBun } from "../../utils/is-bun"; + +describe("isBun", () => { + it("detects whether Bun is the current runtime", ({ expect }) => { + const originalBunVersion = process.versions.bun; + + try { + process.versions.bun = undefined; + expect(isBun()).toBe(false); + + process.versions.bun = "1.4.2"; + expect(isBun()).toBe(true); + } finally { + process.versions.bun = originalBunVersion; + } + }); +}); diff --git a/packages/wrangler/src/api/startDevWorker/ProxyController.ts b/packages/wrangler/src/api/startDevWorker/ProxyController.ts index d507f29bcbb..7b01a94ef06 100644 --- a/packages/wrangler/src/api/startDevWorker/ProxyController.ts +++ b/packages/wrangler/src/api/startDevWorker/ProxyController.ts @@ -26,6 +26,7 @@ import { import { validateHttpsOptions } from "../../https-options"; import { logger } from "../../logger"; import { getSourceMappedStack } from "../../sourcemap"; +import { BUN_UNSUPPORTED_MESSAGE, isBun } from "../../utils/is-bun"; import { Controller } from "./BaseController"; import { castErrorCause } from "./events"; import { createDeferred } from "./utils"; @@ -116,6 +117,12 @@ export class ProxyController extends Controller { }, bindings: { PROXY_CONTROLLER_AUTH_SECRET: this.secret, + // Bun's `fetch()` ignores the undici `dispatcher` that carries + // control messages here, so the ProxyWorker would queue every + // request forever waiting for a `play` message. + ...(isBun() && { + UNSUPPORTED_RUNTIME_MESSAGE: BUN_UNSUPPORTED_MESSAGE, + }), }, // no need to use file-system, so don't diff --git a/packages/wrangler/src/api/test-harness.ts b/packages/wrangler/src/api/test-harness.ts index 16fcf4f3d55..876dd840a55 100644 --- a/packages/wrangler/src/api/test-harness.ts +++ b/packages/wrangler/src/api/test-harness.ts @@ -28,6 +28,7 @@ import { validateNodeCompatMode } from "../deployment-bundle/node-compat"; import { getDurableObjectClassNameToUseSQLiteMap } from "../dev/class-names-sqlite"; import { runWithLogLevel } from "../logger"; import { requireApiToken, requireAuth } from "../user"; +import { BUN_UNSUPPORTED_MESSAGE, isBun } from "../utils/is-bun"; import { DevEnv } from "./startDevWorker/DevEnv"; import { MultiworkerRuntimeController } from "./startDevWorker/MultiworkerRuntimeController"; import { NoOpProxyController } from "./startDevWorker/NoOpProxyController"; @@ -316,6 +317,12 @@ export type TestHarness = { * await server.fetch("http://example.com/api/data"); * // Dispatches a request to the API Worker with URL "http://example.com/api/data" * ``` + * + * Under Bun, this method, `server.getWorker()`'s `fetch()`, `email()` and + * `scheduled()`, and the URL returned by `listen()` cannot reach the Worker, + * because Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes + * requests through. The methods throw a `UserError` and the URL answers with a + * 503. `server.getWorker().getEnv()` and `getExport()` keep working under Bun. */ fetch: DispatchFetch; /** @@ -969,6 +976,16 @@ export function createTestHarness(options?: TestHarnessOptions): TestHarness { worker?: string, event = "fetch" ) { + // Miniflare's `dispatchFetch()` routes every request, `getWorker()` ones + // included, through an undici dispatcher that Bun's `fetch()` ignores, so + // a request can leave for the network. Refuse before sending anything. + if (isBun()) { + throw new UserError( + `${BUN_UNSUPPORTED_MESSAGE} \`server.getWorker().getEnv()\` and \`getExport()\` still work under Bun.`, + { telemetryMessage: "test harness dispatch under bun" } + ); + } + let resolvedInput = input; if (typeof input === "string" && !URL.canParse(input)) { diff --git a/packages/wrangler/src/utils/is-bun.ts b/packages/wrangler/src/utils/is-bun.ts new file mode 100644 index 00000000000..a06d90b5eee --- /dev/null +++ b/packages/wrangler/src/utils/is-bun.ts @@ -0,0 +1,15 @@ +/** + * Returns whether Wrangler is running in the Bun runtime. + * + * @returns Whether Wrangler is running in the Bun runtime. + */ +export function isBun(): boolean { + return !!process.versions.bun; +} + +/** + * Why requests can't reach the Worker under Bun: Bun's `fetch()` ignores the + * undici `dispatcher` that Miniflare routes requests and control messages through. + */ +export const BUN_UNSUPPORTED_MESSAGE = + "Wrangler can't forward requests to your Worker under Bun, because Bun's `fetch()` ignores the undici `dispatcher` that Miniflare routes them through (https://github.com/oven-sh/bun/issues/39247). Run this using Node.js."; diff --git a/packages/wrangler/src/utils/logPossibleBugMessage.ts b/packages/wrangler/src/utils/logPossibleBugMessage.ts index 718ba7b0d84..801bbb94ebe 100644 --- a/packages/wrangler/src/utils/logPossibleBugMessage.ts +++ b/packages/wrangler/src/utils/logPossibleBugMessage.ts @@ -1,12 +1,13 @@ import { logger } from "../logger"; import { updateCheck } from "../update-check"; import { fgGreenColor, resetColor } from "./constants"; +import { isBun } from "./is-bun"; /** * Write a message to the log that tells the user what they might do after we have reported an unexpected error. */ export async function logPossibleBugMessage() { - if (process.versions.bun) { + if (isBun()) { logger.warn( `Wrangler does not support the Bun runtime. Please try this command again using Node.js via \`npm\` or \`pnpm\`. Alternatively, make sure you're not passing the \`--bun\` flag when running \`bun run wrangler ...\`` ); diff --git a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts index 87ca82c8941..0943e7014e9 100644 --- a/packages/wrangler/templates/startDevWorker/ProxyWorker.ts +++ b/packages/wrangler/templates/startDevWorker/ProxyWorker.ts @@ -15,6 +15,9 @@ interface Env { PROXY_CONTROLLER: Fetcher; PROXY_CONTROLLER_AUTH_SECRET: string; DURABLE_OBJECT: DurableObjectNamespace; + // Set when the ProxyController can never reach this Worker, so requests + // fail with this message instead of queueing for a `play` that won't arrive. + UNSUPPORTED_RUNTIME_MESSAGE?: string; } // request.cf.hostMetadata is verbose to type using the workers-types Request -- this allows us to have Request correctly typed in this scope @@ -58,6 +61,12 @@ export class ProxyWorker implements DurableObject { return this.processProxyControllerRequest(request); } + if (this.env.UNSUPPORTED_RUNTIME_MESSAGE) { + return new Response(this.env.UNSUPPORTED_RUNTIME_MESSAGE, { + status: 503, + }); + } + // regular requests to be proxied const deferred = createDeferred();