From 4c492d9dc9d76903ffd706aa80938dee40e10a6b Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 23 Oct 2026 05:36:29 +0000 Subject: [PATCH 1/2] fix(node): scope `FastResponse` subclass `instanceof` and avoid nesting as global `Response` --- src/adapters/_node/response.ts | 31 ++++++++++++++++++-- test/node-global-response.test.ts | 47 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 test/node-global-response.test.ts diff --git a/src/adapters/_node/response.ts b/src/adapters/_node/response.ts index 05b5ca22..844baee7 100644 --- a/src/adapters/_node/response.ts +++ b/src/adapters/_node/response.ts @@ -3,6 +3,8 @@ import { PassThrough, Readable as NodeReadable, Writable as NodeWritable } from import { lazyInherit } from "../../_inherit.ts"; import type { ResponseBody } from "../../types.ts"; +const kNativeResponse = /* @__PURE__ */ Symbol.for("srvx.nativeResponse"); + // prettier-ignore export type PreparedNodeResponseBody = string | Buffer | Uint8Array | DataView | ReadableStream | NodeReadable | undefined | null; @@ -31,9 +33,11 @@ export const NodeResponse: { init?: ResponseInit, ): globalThis.Response & { _toNodeResponse: () => PreparedNodeResponse }; } = /* @__PURE__ */ (() => { - const NativeResponse = globalThis.Response; + const NativeResponse = getNativeResponse(); class NodeResponse implements Partial { + static _srvx = true; + #body?: ResponseBody | null; #init?: ResponseInit; #headers?: Headers; @@ -45,7 +49,13 @@ export const NodeResponse: { } static [Symbol.hasInstance](val: unknown) { - return val instanceof NativeResponse; + // Only widen the check for `NodeResponse` itself. Subclasses inherit this + // static, and when `NodeResponse` is installed as `globalThis.Response`, + // `class X extends Response {}` must not match every response. + if (this === NodeResponse) { + return val instanceof NativeResponse; + } + return Object.prototype.isPrototypeOf.call(this.prototype, val as object); } static json(data: unknown, init?: ResponseInit) { @@ -247,3 +257,20 @@ export const NodeResponse: { })(); export type NodeResponse = InstanceType; + +/** + * Resolve the genuine native `Response` and cache it globally. + * + * Frameworks may install `NodeResponse` as `globalThis.Response`. If srvx is + * evaluated again after that (Vite module runner reload, a second srvx copy), + * capturing the global would wrap the previous `NodeResponse` instead of the + * native class, stacking a layer per reload. Reading the shared cache (and + * unwrapping any srvx class) keeps us on the real native Response. + */ +function getNativeResponse(): typeof globalThis.Response { + let R: any = (globalThis as any)[kNativeResponse] || globalThis.Response; + while (R?._srvx) { + R = Object.getPrototypeOf(R); + } + return ((globalThis as any)[kNativeResponse] ??= R); +} diff --git a/test/node-global-response.test.ts b/test/node-global-response.test.ts new file mode 100644 index 00000000..0536ed9d --- /dev/null +++ b/test/node-global-response.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +// Frameworks (e.g. Nitro) install `FastResponse` as `globalThis.Response` on Node. +describe("FastResponse as global Response", () => { + const Native = globalThis.Response; + + afterEach(() => { + globalThis.Response = Native; + vi.resetModules(); + }); + + async function loadFastResponse() { + vi.resetModules(); + const { NodeResponse } = await import("../src/adapters/_node/response.ts"); + return NodeResponse; + } + + test("instanceof stays scoped for subclasses", async () => { + const FastResponse = await loadFastResponse(); + globalThis.Response = FastResponse as unknown as typeof Response; + + class MyRes extends Response {} + + expect(new Native("x") instanceof FastResponse).toBe(true); + expect(new FastResponse("x") instanceof FastResponse).toBe(true); + expect(new Native("x") instanceof Response).toBe(true); + + expect(new Native("x") instanceof MyRes).toBe(false); + expect(new FastResponse("x") instanceof MyRes).toBe(false); + expect(new MyRes("x") instanceof MyRes).toBe(true); + expect(new MyRes("x") instanceof FastResponse).toBe(true); + }); + + test("re-evaluation wraps the native Response, not a previous FastResponse", async () => { + const F1 = await loadFastResponse(); + globalThis.Response = F1 as unknown as typeof Response; + + const F2 = await loadFastResponse(); + expect(F2).not.toBe(F1); + globalThis.Response = F2 as unknown as typeof Response; + + const F3 = await loadFastResponse(); + expect(Object.getPrototypeOf(F3)).toBe(Native); + expect(Object.getPrototypeOf((new F3("x") as any)._response)).toBe(Native.prototype); + expect(await new F3("hello").text()).toBe("hello"); + }); +}); From 37085df0281a6065d9d1129ffaa08b897d89a4b0 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 23 Oct 2026 05:39:27 +0000 Subject: [PATCH 2/2] test: cover `_srvx` unwrap without the native response cache --- test/node-global-response.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/test/node-global-response.test.ts b/test/node-global-response.test.ts index 0536ed9d..c296c711 100644 --- a/test/node-global-response.test.ts +++ b/test/node-global-response.test.ts @@ -3,13 +3,18 @@ import { afterEach, describe, expect, test, vi } from "vitest"; // Frameworks (e.g. Nitro) install `FastResponse` as `globalThis.Response` on Node. describe("FastResponse as global Response", () => { const Native = globalThis.Response; + const kNativeResponse = Symbol.for("srvx.nativeResponse"); afterEach(() => { globalThis.Response = Native; + delete (globalThis as any)[kNativeResponse]; vi.resetModules(); }); - async function loadFastResponse() { + async function loadFastResponse({ clearCache = false } = {}) { + if (clearCache) { + delete (globalThis as any)[kNativeResponse]; + } vi.resetModules(); const { NodeResponse } = await import("../src/adapters/_node/response.ts"); return NodeResponse; @@ -44,4 +49,14 @@ describe("FastResponse as global Response", () => { expect(Object.getPrototypeOf((new F3("x") as any)._response)).toBe(Native.prototype); expect(await new F3("hello").text()).toBe("hello"); }); + + test("unwraps a srvx global even without the native cache", async () => { + const F1 = await loadFastResponse(); + class MyRes extends (F1 as unknown as typeof Response) {} + globalThis.Response = MyRes; + + const F2 = await loadFastResponse({ clearCache: true }); + expect(Object.getPrototypeOf(F2)).toBe(Native); + expect(Object.getPrototypeOf((new F2("x") as any)._response)).toBe(Native.prototype); + }); });