diff --git a/src/_inherit.ts b/src/_inherit.ts index f838b5c6..bfa36c13 100644 --- a/src/_inherit.ts +++ b/src/_inherit.ts @@ -1,3 +1,31 @@ +/** + * Resolve the runtime's native class behind a (possibly patched) global and + * cache it on `globalThis[key]`. + * + * The global may be replaced by a subclass: srvx's own (`patchGlobalRequest()`, + * `FastResponse` installed as `Response`), another server's (Hono's + * `@hono/node-server` overrides `Request`/`Response`) or a user's. Wrapping such + * a subclass breaks `lazyInherit` (it only copies the subclass's own members, so + * native accessors like `clone()` or `bytes()` hit undici with the wrong + * receiver) and nests a layer per re-evaluation. Native `Request`, `Response` + * and `Headers` are base classes in Node, Bun and Deno, so walking up to the + * class whose parent is `Function.prototype` finds the native one regardless + * of patch order. (Polyfills with their own base class, e.g. node-fetch's + * `Body`, are not supported.) + */ +export function getNativeClass(key: symbol, current: T): T { + let C: any = (globalThis as any)[key] || current; + let P = Object.getPrototypeOf(C); + // Checks `.prototype` rather than `P !== Function.prototype`: globals copied + // from another realm (vitest `vmThreads`, Jest's node environment) inherit + // from that realm's `Function.prototype`, which has no `.prototype`. + while (typeof P === "function" && P.prototype) { + C = P; + P = Object.getPrototypeOf(C); + } + return ((globalThis as any)[key] = C); +} + export function lazyInherit(target: any, source: any, sourceKey: any): void { for (const key of [ ...Object.getOwnPropertyNames(source), diff --git a/src/adapters/_node/request.ts b/src/adapters/_node/request.ts index 330a5f05..8ca770f8 100644 --- a/src/adapters/_node/request.ts +++ b/src/adapters/_node/request.ts @@ -3,7 +3,7 @@ import type { TrustProxyOption } from "../../_trust-proxy.ts"; import { resolveClientIP, trustedHops } from "../../_trust-proxy.ts"; import { NodeRequestURL } from "./url.ts"; import { NodeRequestHeaders } from "./headers.ts"; -import { lazyInherit } from "../../_inherit.ts"; +import { getNativeClass, lazyInherit } from "../../_inherit.ts"; import { createBodyTooLargeError, limitBodyStream } from "../../body-limit.ts"; import { Readable } from "node:stream"; @@ -90,7 +90,7 @@ function isBodySourceFinished(req: NodeServerRequest): boolean { export const NodeRequest: { new (nodeCtx: NodeRequestContext): ServerRequest; } = /* @__PURE__ */ (() => { - const NativeRequest = getNativeRequest(); + const NativeRequest = getNativeClass(kNativeRequest, globalThis.Request); class Request implements Partial { runtime: ServerRequest["runtime"]; @@ -522,7 +522,7 @@ export function patchGlobalRequest(): typeof Request { return globalThis.Request as unknown as typeof Request; } - const NativeRequest = getNativeRequest(); + const NativeRequest = getNativeClass(kNativeRequest, globalThis.Request); const PatchedRequest = class Request extends NativeRequest { static _srvx = true; @@ -617,23 +617,3 @@ function readBody(req: NodeServerRequest, maxRequestBodySize?: number): Promise< req.on("data", onData).once("end", onEnd).once("error", onError).once("close", onClose); }); } - -/** - * Resolve the genuine native `Request` and cache it globally. - * - * `patchGlobalRequest()` replaces `globalThis.Request` with a srvx subclass - * (its prototype has no own body methods). If that patched global is captured - * as the "native" Request — e.g. when a second srvx instance evaluates after - * the global was patched, as happens in a bundled build — then `lazyInherit` - * copies no body methods and `formData()`/`blob()`/`arrayBuffer()`/`bytes()` - * fall through to undici with the wrong receiver and throw. Reading the shared - * cache (and unwrapping any srvx-patched subclass) keeps us on the real native - * Request regardless of patch ordering. - */ -function getNativeRequest(): typeof globalThis.Request { - let R: any = (globalThis as any)[kNativeRequest] || globalThis.Request; - while (R?._srvx) { - R = Object.getPrototypeOf(R); - } - return ((globalThis as any)[kNativeRequest] ??= R); -} diff --git a/src/adapters/_node/response.ts b/src/adapters/_node/response.ts index 844baee7..75067674 100644 --- a/src/adapters/_node/response.ts +++ b/src/adapters/_node/response.ts @@ -1,6 +1,6 @@ import { PassThrough, Readable as NodeReadable, Writable as NodeWritable } from "node:stream"; -import { lazyInherit } from "../../_inherit.ts"; +import { getNativeClass, lazyInherit } from "../../_inherit.ts"; import type { ResponseBody } from "../../types.ts"; const kNativeResponse = /* @__PURE__ */ Symbol.for("srvx.nativeResponse"); @@ -33,11 +33,9 @@ export const NodeResponse: { init?: ResponseInit, ): globalThis.Response & { _toNodeResponse: () => PreparedNodeResponse }; } = /* @__PURE__ */ (() => { - const NativeResponse = getNativeResponse(); + const NativeResponse = getNativeClass(kNativeResponse, globalThis.Response); class NodeResponse implements Partial { - static _srvx = true; - #body?: ResponseBody | null; #init?: ResponseInit; #headers?: Headers; @@ -257,20 +255,3 @@ 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 index c296c711..f81a4662 100644 --- a/test/node-global-response.test.ts +++ b/test/node-global-response.test.ts @@ -1,4 +1,15 @@ +import { runInNewContext } from "node:vm"; import { afterEach, describe, expect, test, vi } from "vitest"; +import { getNativeClass } from "../src/_inherit.ts"; + +describe("getNativeClass", () => { + test("stops at a base class from another realm", () => { + const Foreign = runInNewContext("(class Response {})"); + class Sub extends Foreign {} + expect(getNativeClass(Symbol(), Foreign)).toBe(Foreign); + expect(getNativeClass(Symbol(), Sub)).toBe(Foreign); + }); +}); // Frameworks (e.g. Nitro) install `FastResponse` as `globalThis.Response` on Node. describe("FastResponse as global Response", () => { @@ -60,3 +71,50 @@ describe("FastResponse as global Response", () => { expect(Object.getPrototypeOf((new F2("x") as any)._response)).toBe(Native.prototype); }); }); + +// `@hono/node-server` replaces `globalThis.Request`/`Response` with lightweight +// subclasses that lack some native members. srvx evaluated after that must still +// wrap the native classes. +describe("globals patched by @hono/node-server", () => { + const NativeRequest = globalThis.Request; + const NativeResponse = globalThis.Response; + const kNativeRequest = Symbol.for("srvx.nativeRequest"); + const kNativeResponse = Symbol.for("srvx.nativeResponse"); + + afterEach(() => { + globalThis.Request = NativeRequest; + globalThis.Response = NativeResponse; + vi.resetModules(); + }); + + test("srvx loaded after Hono wraps the native classes", async () => { + const { getRequestListener } = await import("@hono/node-server"); + getRequestListener(() => new Response()); + expect(globalThis.Request).not.toBe(NativeRequest); + expect(globalThis.Response).not.toBe(NativeResponse); + + delete (globalThis as any)[kNativeRequest]; + delete (globalThis as any)[kNativeResponse]; + vi.resetModules(); + const { serve, FastResponse } = await import("../src/adapters/node.ts"); + + expect(Object.getPrototypeOf(FastResponse)).toBe(NativeResponse); + expect(await new FastResponse("hi").bytes()).toEqual(new TextEncoder().encode("hi")); + + const server = serve({ + port: 0, + silent: true, + fetch: async (req) => { + const clone = req.clone(); + return new FastResponse(`${req.cache}|${req.mode}|${await clone.text()}`); + }, + }); + try { + await server.ready(); + const res = await fetch(server.url!, { method: "POST", body: "body" }); + expect(await res.text()).toBe("default|cors|body"); + } finally { + await server.close(true); + } + }); +});