diff --git a/.changeset/preserve-binding-error-codes.md b/.changeset/preserve-binding-error-codes.md new file mode 100644 index 00000000000..4cdf537fc5b --- /dev/null +++ b/.changeset/preserve-binding-error-codes.md @@ -0,0 +1,10 @@ +--- +"miniflare": patch +"wrangler": patch +--- + +Preserve binding error codes and names in local development + +Errors from remote bindings and from Node.js binding proxies (`getPlatformProxy()` and `Miniflare#getBindings()`) now keep their machine-readable properties, such as Artifacts' `code` and `numericCode`. Previously these properties were dropped and the error name was folded into the message (for example `"ArtifactsError: Repository not found"`), so code could only branch on error text during local development. + +Node.js callers also keep custom error names such as `ArtifactsError`. Errors from remote bindings received inside a Worker keep their properties but are still named `Error`. diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 502bd098409..a645bd3a5d5 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -946,7 +946,13 @@ export function getGlobalServices({ worker: { modules: [{ name: "entry.worker.js", esModule: SCRIPT_ENTRY() }], compatibilityDate: "2025-03-17", - compatibilityFlags: ["nodejs_compat", "service_binding_extra_handlers"], + compatibilityFlags: [ + "nodejs_compat", + "service_binding_extra_handlers", + // Keep custom error names and properties (for example Artifacts + // `code`) when binding errors reach `ProxyServer` for Node callers. + "enhanced_error_serialization", + ], bindings: serviceEntryBindings, durableObjectNamespaces: [ { diff --git a/packages/miniflare/src/plugins/shared/constants.ts b/packages/miniflare/src/plugins/shared/constants.ts index 66dbe5ebd8b..3ab9593da4f 100644 --- a/packages/miniflare/src/plugins/shared/constants.ts +++ b/packages/miniflare/src/plugins/shared/constants.ts @@ -140,11 +140,15 @@ export function remoteProxyClientWorker( ) { return { compatibilityDate: "2025-01-01", - // Raw TCP bindings (e.g. VPC networks) tunnel `binding.connect()` traffic - // through this worker's inbound `connect` handler, which requires the - // `experimental` compatibility flag (workerd#6059). Other bindings only - // proxy HTTP/JSRPC and must not opt in. - ...(options?.rawTcp ? { compatibilityFlags: ["experimental"] } : {}), + // `enhanced_error_serialization` keeps custom error names and properties + // (for example Artifacts `code` and `numericCode`) when this worker rethrows + // remote errors to the user Worker. Raw TCP bindings (e.g. VPC networks) + // tunnel `binding.connect()` traffic through this worker's inbound `connect` + // handler, which also requires the `experimental` compatibility flag + // (workerd#6059). Other bindings only proxy HTTP/JSRPC and must not opt in. + compatibilityFlags: options?.rawTcp + ? ["experimental", "enhanced_error_serialization"] + : ["enhanced_error_serialization"], modules: [ { name: "index.worker.js", diff --git a/packages/miniflare/src/workers/core/devalue.ts b/packages/miniflare/src/workers/core/devalue.ts index 2c92c755d8f..ecdad3ec355 100644 --- a/packages/miniflare/src/workers/core/devalue.ts +++ b/packages/miniflare/src/workers/core/devalue.ts @@ -84,14 +84,56 @@ export const structuredSerializableReducers: ReducersRevivers = { Error(value) { for (const ctor of ALLOWED_ERROR_CONSTRUCTORS) { if (value instanceof ctor && value.name === ctor.name) { - return [value.name, value.message, value.stack, value.cause]; + return serializeError(value, value.name); } } if (value instanceof Error) { - return ["Error", value.message, value.stack, value.cause]; + return serializeError(value, "Error"); } }, }; + +const RESERVED_ERROR_KEYS = new Set(["name", "message", "stack", "cause"]); + +function isPrimitiveErrorProperty( + value: unknown +): value is string | number | boolean | null { + return ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +// Errors from bindings carry machine-readable properties such as Artifacts' +// `code` and `numericCode`, and custom names such as `ArtifactsError`. Preserve +// primitive own properties and the original name so Node callers can branch on +// them. Like workerd's `enhanced_error_serialization`, this includes +// non-enumerable properties. Non-primitive properties are skipped so they +// cannot make an error unserialisable. +function serializeError(error: Error, constructorName: string): unknown[] { + const properties: Record = {}; + for (const key of Object.getOwnPropertyNames(error)) { + const property = (error as unknown as Record)[key]; + if (!RESERVED_ERROR_KEYS.has(key) && isPrimitiveErrorProperty(property)) { + properties[key] = property; + } + } + if (typeof error.name === "string" && error.name !== constructorName) { + properties.name = error.name; + } + const serialized: unknown[] = [ + constructorName, + error.message, + error.stack, + error.cause, + ]; + if (Object.keys(properties).length > 0) { + serialized.push(properties); + } + return serialized; +} export const structuredSerializableRevivers: ReducersRevivers = { ArrayBuffer(value) { assert(Array.isArray(value)); @@ -130,7 +172,7 @@ export const structuredSerializableRevivers: ReducersRevivers = { }, Error(value) { assert(Array.isArray(value)); - const [name, message, stack, cause] = value as unknown[]; + const [name, message, stack, cause, properties] = value as unknown[]; assert(typeof name === "string"); assert(typeof message === "string"); assert(stack === undefined || typeof stack === "string"); @@ -140,6 +182,22 @@ export const structuredSerializableRevivers: ReducersRevivers = { assert(ALLOWED_ERROR_CONSTRUCTORS.includes(ctor)); const error = new ctor(message, { cause }); error.stack = stack; + if (properties !== undefined) { + assert(typeof properties === "object" && properties !== null); + for (const [key, property] of Object.entries(properties)) { + if ( + (key === "name" || !RESERVED_ERROR_KEYS.has(key)) && + isPrimitiveErrorProperty(property) + ) { + Object.defineProperty(error, key, { + value: property, + writable: true, + enumerable: key !== "name", + configurable: true, + }); + } + } + } return error; }, }; diff --git a/packages/miniflare/test/plugins/core/proxy/client.spec.ts b/packages/miniflare/test/plugins/core/proxy/client.spec.ts index 2fe79571885..04e5ed2c704 100644 --- a/packages/miniflare/test/plugins/core/proxy/client.spec.ts +++ b/packages/miniflare/test/plugins/core/proxy/client.spec.ts @@ -80,6 +80,72 @@ describe("ProxyClient", () => { expect(event.data).toBe("echo:hello"); }); + test("preserves error codes and names thrown by RPC bindings", async ({ + expect, + }) => { + const mf = new Miniflare({ + workers: [ + { + config: { + name: "", + compatibilityDate: "2025-05-01", + env: { + BINDING: { + type: "worker", + worker: "binding", + exportName: "BindingEntrypoint", + }, + }, + }, + legacy: { serviceWorkerScript: nullScript }, + }, + { + config: { + name: "binding", + compatibilityDate: "2025-05-01", + compatibilityFlags: ["enhanced_error_serialization"], + manifest: singleModuleManifest(` + import { WorkerEntrypoint } from "cloudflare:workers"; + class BindingError extends Error { + name = "BindingError"; + constructor(message, code, numericCode) { + super(message); + this.code = code; + this.numericCode = numericCode; + } + } + export class BindingEntrypoint extends WorkerEntrypoint { + get(name) { + throw new BindingError("Repository not found: " + name + ".", "NOT_FOUND", 10200); + } + } + export default { fetch() { return new Response(null, { status: 404 }); } }; + `), + }, + }, + ], + }); + useDispose(mf); + + const { BINDING } = await mf.getBindings<{ + BINDING: { get(name: string): Promise }; + }>(); + + let error: unknown; + try { + await BINDING.get("missing"); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ + name: "BindingError", + message: "Repository not found: missing.", + code: "NOT_FOUND", + numericCode: 10200, + }); + }); + test("preserves original URL for service binding fetch", async ({ expect, }) => { diff --git a/packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts b/packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts index bfa551d61b1..aa195bb8fe4 100644 --- a/packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts +++ b/packages/miniflare/test/plugins/shared/remote-bindings-connect.spec.ts @@ -734,7 +734,7 @@ describe("remoteProxyClientWorker: raw TCP opt-in", () => { const worker = remoteProxyClientWorker(); expect( (worker as { compatibilityFlags?: string[] }).compatibilityFlags - ).toBeUndefined(); + ).toEqual(["enhanced_error_serialization"]); }); test("enables the experimental flag when rawTcp is set", ({ expect }) => { @@ -743,7 +743,7 @@ describe("remoteProxyClientWorker: raw TCP opt-in", () => { }); expect( (worker as { compatibilityFlags?: string[] }).compatibilityFlags - ).toEqual(["experimental"]); + ).toEqual(["experimental", "enhanced_error_serialization"]); }); }); @@ -771,7 +771,10 @@ describe("VPC_SERVICES plugin: raw TCP opt-in", () => { worker?: { compatibilityFlags?: string[] }; }>; expect(serviceList).toHaveLength(1); - expect(serviceList[0].worker?.compatibilityFlags).toEqual(["experimental"]); + expect(serviceList[0].worker?.compatibilityFlags).toEqual([ + "experimental", + "enhanced_error_serialization", + ]); }); }); diff --git a/packages/miniflare/test/workers/core/serialize.spec.ts b/packages/miniflare/test/workers/core/serialize.spec.ts index 382238234f4..ec30e7a3492 100644 --- a/packages/miniflare/test/workers/core/serialize.spec.ts +++ b/packages/miniflare/test/workers/core/serialize.spec.ts @@ -100,3 +100,64 @@ test("does not treat an object with a forged Headers tag as Headers", ({ /symbolic keys/ ); }); + +test("serialize Error preserves primitive own properties and custom names", ({ + expect, +}) => { + const input = Object.assign(new Error("Repository not found: missing."), { + code: "NOT_FOUND", + retryable: false, + details: null, + }); + Object.defineProperty(input, "numericCode", { value: 10200 }); + input.name = "ArtifactsError"; + + const serialized = stringify(input, structuredSerializableReducers); + const deserialized = parse(serialized, structuredSerializableRevivers); + + expect(deserialized).toBeInstanceOf(Error); + expect(deserialized).toMatchObject({ + name: "ArtifactsError", + message: "Repository not found: missing.", + code: "NOT_FOUND", + numericCode: 10200, + retryable: false, + details: null, + }); + expect(Object.keys(deserialized)).not.toContain("name"); +}); + +test("serialize Error keeps built-in constructors and skips non-primitive properties", ({ + expect, +}) => { + const input = Object.assign(new TypeError("bad input"), { + code: "INVALID_INPUT", + callback: () => {}, + nested: { value: 1 }, + }); + + const serialized = stringify(input, structuredSerializableReducers); + const deserialized = parse(serialized, structuredSerializableRevivers); + + expect(deserialized).toBeInstanceOf(TypeError); + expect(deserialized.name).toBe("TypeError"); + expect(deserialized.code).toBe("INVALID_INPUT"); + expect(deserialized).not.toHaveProperty("callback"); + expect(deserialized).not.toHaveProperty("nested"); +}); + +test("serialize Error without extra properties keeps the existing encoding", ({ + expect, +}) => { + const input = new RangeError("out of range"); + input.stack = "stack"; + + const serialized = stringify(input, structuredSerializableReducers); + + expect(serialized).toBe( + '[["Error",1],[2,3,4,-1],"RangeError","out of range","stack"]' + ); + const deserialized = parse(serialized, structuredSerializableRevivers); + expect(deserialized).toBeInstanceOf(RangeError); + expect(deserialized.message).toBe("out of range"); +}); diff --git a/packages/remote-bindings/src/start-remote-proxy-session.ts b/packages/remote-bindings/src/start-remote-proxy-session.ts index aa4d9d78daa..5f265a56101 100644 --- a/packages/remote-bindings/src/start-remote-proxy-session.ts +++ b/packages/remote-bindings/src/start-remote-proxy-session.ts @@ -122,7 +122,9 @@ export async function startRemoteProxySession( cliDisplayName: options.cliDisplayName, entrypointSource: remoteBindingsWorkerSource, compatibilityDate: "2025-04-28", - compatibilityFlags: [], + // Keep custom error names and properties (for example Artifacts `code` and + // `numericCode`) when errors cross from the remote binding into this worker. + compatibilityFlags: ["enhanced_error_serialization"], complianceRegion: options.complianceRegion, bindings: rawBindings, auth: getRemoteBindingsAuthHook(