Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 29 additions & 2 deletions src/adapters/_node/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Response> {
static _srvx = true;

#body?: ResponseBody | null;
#init?: ResponseInit;
#headers?: Headers;
Expand All @@ -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) {
Expand Down Expand Up @@ -247,3 +257,20 @@ export const NodeResponse: {
})();

export type NodeResponse = InstanceType<typeof NodeResponse>;

/**
* 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);
}
62 changes: 62 additions & 0 deletions test/node-global-response.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading