diff --git a/.changeset/fix-cast-error-cause-message.md b/.changeset/fix-cast-error-cause-message.md new file mode 100644 index 00000000000..453a243509e --- /dev/null +++ b/.changeset/fix-cast-error-cause-message.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Surface the original error message, name and stack when the dev server reports an internal error + +Errors reported by the ProxyWorker cross a JSON channel, so they arrived as plain objects and were previously wrapped in a message-less `Error`, causing `wrangler dev` to exit with an empty `✘ [ERROR]` log. Such errors (e.g. `Network connection lost.`, see #14641) are now rehydrated with their original message, name and stack so the failure is actually diagnosable. diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts index 3af59552994..f5a08b65136 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/ProxyController.test.ts @@ -1,11 +1,38 @@ import { describe, test } from "vitest"; +import { serialiseError } from "../../../api/startDevWorker/events"; import { ProxyController } from "../../../api/startDevWorker/ProxyController"; import { FakeBus } from "../../helpers/fake-bus"; import { mockConsoleMethods } from "../../helpers/mock-console"; +import type { SerializedError } from "../../../api/startDevWorker/events"; describe("ProxyController", () => { mockConsoleMethods(); + test("ProxyWorker error reports preserve message/name/stack across the JSON channel", async ({ + expect, + }) => { + // Regression test for https://github.com/cloudflare/workers-sdk/issues/14641: + // the ProxyWorker's error reports arrive as JSON-serialized plain objects, + // and used to be re-wrapped in a message-less Error, so the resulting + // fatal log was an empty `✘ [ERROR]` with no clue about the failure. + const bus = new FakeBus(); + const controller = new ProxyController(bus); + const waited = bus.waitFor("error"); + + const original = new Error("Network connection lost."); + const serialized = JSON.parse( + JSON.stringify(serialiseError(original)) + ) as SerializedError; + controller.onProxyWorkerMessage({ type: "error", error: serialized }); + + const event = await waited; + expect(event.source).toBe("ProxyController"); + expect(event.reason).toBe("Error inside ProxyWorker"); + expect(event.cause).toBeInstanceOf(Error); + expect(event.cause.message).toBe("Network connection lost."); + expect(event.cause.stack).toBe(original.stack); + }); + test("Runtime.exceptionThrown dispatches a typed runtimeError event", async ({ expect, }) => { diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/events.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/events.test.ts new file mode 100644 index 00000000000..1d5cd3ce65b --- /dev/null +++ b/packages/wrangler/src/__tests__/api/startDevWorker/events.test.ts @@ -0,0 +1,74 @@ +import { describe, it } from "vitest"; +import { + castErrorCause, + serialiseError, +} from "../../../api/startDevWorker/events"; + +describe("castErrorCause", () => { + it("returns Error instances unchanged", ({ expect }) => { + const cause = new TypeError("boom"); + expect(castErrorCause(cause)).toBe(cause); + }); + + it("rehydrates a SerializedError, preserving message/name/stack", ({ + expect, + }) => { + // Regression test for https://github.com/cloudflare/workers-sdk/issues/14641: + // the ProxyWorker's error reports cross a JSON channel, so they arrive as + // plain objects. castErrorCause used to wrap them in a message-less + // `new Error()`, making the fatal log an empty `✘ [ERROR]` with no clue + // about the actual failure (e.g. "Network connection lost."). + const original = new Error("Network connection lost."); + const serialized = JSON.parse( + JSON.stringify(serialiseError(original)) + ) as unknown; + + const error = castErrorCause(serialized); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("Network connection lost."); + expect(error.name).toBe("Error"); + expect(error.stack).toBe(original.stack); + // No own `cause: undefined` property that would render as + // `{ cause: undefined }` in util.format-based debug logs + expect(Object.hasOwn(error, "cause")).toBe(false); + }); + + it("rehydrates the serialized nested cause chain into Error instances", ({ + expect, + }) => { + const original = new Error("outer", { cause: new TypeError("inner") }); + const serialized = JSON.parse( + JSON.stringify(serialiseError(original)) + ) as unknown; + + const error = castErrorCause(serialized); + + expect(error.message).toBe("outer"); + expect(error.cause).toBeInstanceOf(Error); + const inner = error.cause as Error; + expect(inner.message).toBe("inner"); + expect(inner.name).toBe("TypeError"); + }); + + it("wraps other non-Error causes, keeping them as `cause`", ({ expect }) => { + const error = castErrorCause("string cause"); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe(""); + expect(error.cause).toBe("string cause"); + }); + + it("preserves message-bearing objects with extra properties verbatim", ({ + expect, + }) => { + // Not a SerializedError (extra `code` key): rehydrating would silently + // drop `code`, so the object must be kept whole as `cause` instead + const cause = { message: "failed", code: "E_CUSTOM" }; + + const error = castErrorCause(cause); + + expect(error.message).toBe(""); + expect(error.cause).toBe(cause); + }); +}); diff --git a/packages/wrangler/src/api/startDevWorker/events.ts b/packages/wrangler/src/api/startDevWorker/events.ts index 79ce57502f5..876b4244858 100644 --- a/packages/wrangler/src/api/startDevWorker/events.ts +++ b/packages/wrangler/src/api/startDevWorker/events.ts @@ -34,12 +34,54 @@ export function castErrorCause(cause: unknown) { return cause; } + // Errors that cross a JSON channel (e.g. the ProxyWorker's and + // InspectorProxyWorker's error reports) arrive as SerializedError plain + // objects — rehydrate them so their message/name/stack are surfaced + // instead of an empty `new Error()`. + if (isSerializedError(cause)) { + const error = new Error(cause.message); + if (cause.name !== undefined) { + error.name = cause.name; + } + if (cause.stack !== undefined) { + error.stack = cause.stack; + } + if (cause.cause !== undefined) { + // Rehydrate serialized cause chains (serialiseError recurses) so the + // `instanceof Error` cause-chain classifiers in handle-errors.ts work + // across the channel; leave non-SerializedError causes untouched. + error.cause = isSerializedError(cause.cause) + ? castErrorCause(cause.cause) + : cause.cause; + } + return error; + } + const error = new Error(); error.cause = cause; return error; } +const serializedErrorKeys = new Set(["message", "name", "stack", "cause"]); + +// Matches exactly the shape produced by serialiseError() and the +// ProxyWorker's error reports. Kept strict (no extra keys) so that arbitrary +// message-bearing objects passed to castErrorCause() are still preserved +// verbatim as `cause` rather than lossily rehydrated. +function isSerializedError(value: unknown): value is SerializedError { + if (typeof value !== "object" || value === null) { + return false; + } + const record = value as Record; + return ( + typeof record.message === "string" && + (record.name === undefined || typeof record.name === "string") && + (record.stack === undefined || typeof record.stack === "string") && + Object.keys(record).every((key) => serializedErrorKeys.has(key)) + ); +} + // ConfigController export type ConfigUpdateEvent = { type: "configUpdate";