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..c296c711 --- /dev/null +++ b/test/node-global-response.test.ts @@ -0,0 +1,62 @@ +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({ clearCache = false } = {}) { + if (clearCache) { + delete (globalThis as any)[kNativeResponse]; + } + 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"); + }); + + 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); + }); +});