Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
caca08d
Fail early when `createTestHarness()` cannot start its proxy under Bun
dario-piotrowicz Sep 24, 2026
850adad
[wrangler] Keep getWorker() working under Bun and fail only the proxy…
oddharsh Sep 24, 2026
29d0fa7
[wrangler] Fail late retries under a missing control payload, and cov…
oddharsh Sep 24, 2026
4712825
[wrangler] Refuse every harness dispatch under Bun, since #15906 rout…
oddharsh Oct 2, 2026
e7ff0ca
Retrigger CI after Windows timeouts in @fixture/entrypoints-rpc and w…
oddharsh Oct 7, 2026
4593180
Merge branch 'main' into oddharsh/bun-harness-keep-getworker
oddharsh Oct 7, 2026
f826e0b
Merge remote-tracking branch 'origin/main' into oddharsh/bun-harness-…
oddharsh Oct 19, 2026
ab5abe1
[remote-bindings] Fail remote proxy startup under Bun instead of hang…
oddharsh Oct 19, 2026
63eb034
[wrangler] Report the missing control payload to the ProxyWorker dire…
oddharsh Oct 19, 2026
5e8cb23
[remote-bindings] Keep real startup errors under Bun, and confirm pla…
oddharsh Oct 19, 2026
e0a5086
[wrangler] Send the missing-payload control request with undici's fetch
oddharsh Oct 19, 2026
d3a474e
[remote-bindings] Clear latestSendError on every send so it never des…
oddharsh Oct 19, 2026
27c060e
Retrigger CI after a slow Windows runner timed out @fixture/start-wor…
oddharsh Oct 19, 2026
fb3cfe0
Merge branch 'main' into oddharsh/bun-harness-keep-getworker
oddharsh Oct 19, 2026
617272b
[wrangler] Replace Bun delivery tracking with flat isBun() checks
oddharsh Oct 19, 2026
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
9 changes: 9 additions & 0 deletions .changeset/fuzzy-bears-listen.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions packages/remote-bindings/src/start-remote-proxy-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/remote-bindings/src/start-remote-proxy-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
);
}
Comment thread
petebacondarwin marked this conversation as resolved.

options.logger.log(chalk.dim("⎔ Establishing remote connection..."));
initLogger(getInternalLogger(options.logger));
const rawBindings = toRawBindings(bindings);
Expand Down
66 changes: 66 additions & 0 deletions packages/wrangler/src/__tests__/api/test-harness.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../../utils/is-bun")>()),
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);
});
});
18 changes: 18 additions & 0 deletions packages/wrangler/src/__tests__/utils/is-bun.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
});
7 changes: 7 additions & 0 deletions packages/wrangler/src/api/startDevWorker/ProxyController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions packages/wrangler/src/api/test-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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)) {
Expand Down
15 changes: 15 additions & 0 deletions packages/wrangler/src/utils/is-bun.ts
Original file line number Diff line number Diff line change
@@ -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.";
3 changes: 2 additions & 1 deletion packages/wrangler/src/utils/logPossibleBugMessage.ts
Original file line number Diff line number Diff line change
@@ -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 ...\``
);
Expand Down
9 changes: 9 additions & 0 deletions packages/wrangler/templates/startDevWorker/ProxyWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Response>();

Expand Down
Loading