diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index 9f0475aa..d0a450e5 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -346,6 +346,16 @@ app.get("/", (event) => { }); ``` +### `requestWithBaseURL(req, base)` + +Create a lightweight request proxy with the base path stripped from the URL pathname. + +### `requestWithURL(req, url)` + +Create a lightweight request proxy that overrides only the URL. + +Avoids cloning the original request (no `new Request()` allocation). + ### `toRequest(input, options?)` Convert input into a web [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request). diff --git a/src/h3.ts b/src/h3.ts index 34644f6d..7d81c020 100644 --- a/src/h3.ts +++ b/src/h3.ts @@ -2,6 +2,7 @@ import { createRouter, addRoute, findRoute } from "rou3"; import { H3Event } from "./event.ts"; import { toResponse, kNotFound } from "./response.ts"; import { callMiddleware, normalizeMiddleware } from "./middleware.ts"; +import { requestWithBaseURL } from "./utils/request.ts"; import type { ServerRequest } from "srvx"; import type { H3Config, H3CoreConfig, H3Plugin, MatchedRoute, RouterContext } from "./types/h3.ts"; @@ -142,9 +143,7 @@ export const H3 = /* @__PURE__ */ (() => { } else { const fetchHandler = "fetch" in input ? input.fetch : input; this.all(`${base}/**`, function _mountedMiddleware(event) { - const url = new URL(event.url); - url.pathname = url.pathname.slice(base.length) || "/"; - return fetchHandler(new Request(url, event.req)); + return fetchHandler(requestWithBaseURL(event.req, base)); }); } return this; diff --git a/src/index.ts b/src/index.ts index 7bd98ce3..79807c1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,8 @@ export { type RouteDefinition, defineRoute } from "./utils/route.ts"; // Request export { + requestWithURL, + requestWithBaseURL, toRequest, getRequestHost, getRequestIP, diff --git a/src/utils/request.ts b/src/utils/request.ts index 22b5cfb2..a99f6743 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -11,6 +11,36 @@ import type { HTTPMethod } from "../types/h3.ts"; import type { H3EventContext } from "../types/context.ts"; import type { ServerRequest } from "srvx"; +const _urlOverrides = new WeakMap(); + +const _proxyHandler: ProxyHandler = { + get(target, prop, receiver) { + if (prop === "url") return _urlOverrides.get(receiver); + const value = Reflect.get(target, prop); + return typeof value === "function" ? value.bind(target) : value; + }, +}; + +/** + * Create a lightweight request proxy that overrides only the URL. + * + * Avoids cloning the original request (no `new Request()` allocation). + */ +export function requestWithURL(req: ServerRequest, url: string): ServerRequest { + const proxy = new Proxy(req, _proxyHandler); + _urlOverrides.set(proxy, url); + return proxy; +} + +/** + * Create a lightweight request proxy with the base path stripped from the URL pathname. + */ +export function requestWithBaseURL(req: ServerRequest, base: string): ServerRequest { + const url = new URL(req.url); + url.pathname = url.pathname.slice(base.length) || "/"; + return requestWithURL(req, url.href); +} + /** * Convert input into a web [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request). * diff --git a/test/unit/package.test.ts b/test/unit/package.test.ts index 9f50bb18..a7705641 100644 --- a/test/unit/package.test.ts +++ b/test/unit/package.test.ts @@ -106,6 +106,8 @@ describe("h3 package", () => { "readValidatedBody", "redirect", "removeResponseHeader", + "requestWithBaseURL", + "requestWithURL", "requireBasicAuth", "sanitizeStatusCode", "sanitizeStatusMessage", diff --git a/test/unit/request.test.ts b/test/unit/request.test.ts new file mode 100644 index 00000000..610d8324 --- /dev/null +++ b/test/unit/request.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { requestWithURL, requestWithBaseURL } from "../../src/utils/request.ts"; + +describe("requestWithURL", () => { + const original = new Request("http://example.com/base/path", { + method: "POST", + headers: { "x-test": "value" }, + body: "hello", + }); + + it("overrides url", () => { + const proxied = requestWithURL(original, "http://example.com/path"); + expect(proxied.url).toBe("http://example.com/path"); + }); + + it("preserves original url on source request", () => { + requestWithURL(original, "http://example.com/path"); + expect(original.url).toBe("http://example.com/base/path"); + }); + + it("preserves method", () => { + const proxied = requestWithURL(original, "http://example.com/path"); + expect(proxied.method).toBe("POST"); + }); + + it("preserves headers", () => { + const proxied = requestWithURL(original, "http://example.com/path"); + expect(proxied.headers.get("x-test")).toBe("value"); + }); + + it("is instanceof Request", () => { + const proxied = requestWithURL(original, "http://example.com/path"); + expect(proxied instanceof Request).toBe(true); + }); + + it("clone() works and keeps overridden url", () => { + const proxied = requestWithURL(original, "http://example.com/path"); + const cloned = proxied.clone(); + expect(cloned.url).toBe("http://example.com/base/path"); + expect(cloned.method).toBe("POST"); + }); +}); + +describe("requestWithBaseURL", () => { + const original = new Request("http://example.com/base/path?q=1", { + method: "POST", + headers: { "x-test": "value" }, + body: "hello", + }); + + it("strips base from pathname", () => { + const proxied = requestWithBaseURL(original, "/base"); + expect(proxied.url).toBe("http://example.com/path?q=1"); + }); + + it("returns / when pathname equals base", () => { + const req = new Request("http://example.com/base"); + const proxied = requestWithBaseURL(req, "/base"); + expect(new URL(proxied.url).pathname).toBe("/"); + }); + + it("preserves query string", () => { + const proxied = requestWithBaseURL(original, "/base"); + expect(new URL(proxied.url).search).toBe("?q=1"); + }); + + it("preserves method and headers", () => { + const proxied = requestWithBaseURL(original, "/base"); + expect(proxied.method).toBe("POST"); + expect(proxied.headers.get("x-test")).toBe("value"); + }); + + it("is instanceof Request", () => { + const proxied = requestWithBaseURL(original, "/base"); + expect(proxied instanceof Request).toBe(true); + }); +});