diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 79d71b8cc..7c228a295 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1631,7 +1631,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const mwUrl = new URL(request.url); mwUrl.pathname = cleanPathname; const mwRequest = new Request(mwUrl, request); - const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); + const __mwNextConfig = (__basePath || __i18nConfig) ? { basePath: __basePath, i18n: __i18nConfig ?? undefined } : undefined; + const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest, __mwNextConfig ? { nextConfig: __mwNextConfig } : undefined); const mwFetchEvent = new NextFetchEvent({ page: cleanPathname }); const mwResponse = await middlewareFn(nextRequest, mwFetchEvent); const _mwWaitUntil = mwFetchEvent.drainWaitUntil(); diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 0736a2a14..59fd255fd 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -196,7 +196,8 @@ async function _runMiddleware(request) { mwUrl.pathname = normalizedPathname; mwRequest = new Request(mwUrl, request); } - var nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); + var __mwNextConfig = (vinextConfig.basePath || i18nConfig) ? { basePath: vinextConfig.basePath, i18n: i18nConfig || undefined } : undefined; + var nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest, __mwNextConfig ? { nextConfig: __mwNextConfig } : undefined); var fetchEvent = new NextFetchEvent({ page: normalizedPathname }); var response; try { response = await middlewareFn(nextRequest, fetchEvent); } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 9f8f630e0..18cb8815a 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2766,6 +2766,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { middlewarePath, middlewareRequest, nextConfig?.i18n, + nextConfig?.basePath, ); if (!result.continue) { diff --git a/packages/vinext/src/server/middleware.ts b/packages/vinext/src/server/middleware.ts index 5bb3dec56..efdce4ad8 100644 --- a/packages/vinext/src/server/middleware.ts +++ b/packages/vinext/src/server/middleware.ts @@ -394,6 +394,7 @@ export async function runMiddleware( middlewarePath: string, request: Request, i18nConfig?: NextI18nConfig | null, + basePath?: string, ): Promise { // Load the middleware module via the direct-call ModuleRunner. // This bypasses the hot channel entirely and is safe with all Vite plugin @@ -435,7 +436,14 @@ export async function runMiddleware( } // Wrap in NextRequest so middleware gets .nextUrl, .cookies, .geo, .ip, etc. - const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); + const nextConfig = + basePath || i18nConfig + ? { basePath: basePath ?? "", i18n: i18nConfig ?? undefined } + : undefined; + const nextRequest = + mwRequest instanceof NextRequest + ? mwRequest + : new NextRequest(mwRequest, nextConfig ? { nextConfig } : undefined); const fetchEvent = new NextFetchEvent({ page: normalizedPathname }); // Execute the middleware diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 39b951146..2c22c0354 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -59,7 +59,18 @@ export class NextRequest extends Request { private _nextUrl: NextURL; private _cookies: RequestCookies; - constructor(input: URL | RequestInfo, init?: RequestInit) { + constructor( + input: URL | RequestInfo, + init?: RequestInit & { + nextConfig?: { + basePath?: string; + i18n?: { locales: string[]; defaultLocale: string }; + }; + }, + ) { + // Strip nextConfig before passing to super() — it's vinext-internal, + // not a valid RequestInit property. + const { nextConfig: _nextConfig, ...requestInit } = init ?? {}; // Handle the case where input is a Request object - we need to extract URL and init // to avoid Node.js undici issues with passing Request objects directly to super() if (input instanceof Request) { @@ -70,10 +81,10 @@ export class NextRequest extends Request { body: req.body, // @ts-expect-error - duplex is not in RequestInit type but needed for streams duplex: req.body ? "half" : undefined, - ...init, + ...requestInit, }); } else { - super(input, init); + super(input, requestInit); } const url = typeof input === "string" @@ -81,7 +92,10 @@ export class NextRequest extends Request { : input instanceof URL ? input : new URL(input.url, "http://localhost"); - this._nextUrl = new NextURL(url); + const urlConfig: NextURLConfig | undefined = _nextConfig + ? { basePath: _nextConfig.basePath, nextConfig: { i18n: _nextConfig.i18n } } + : undefined; + this._nextUrl = new NextURL(url, undefined, urlConfig); this._cookies = new RequestCookies(this.headers); } @@ -216,18 +230,86 @@ export class NextResponse<_Body = unknown> extends Response { // NextURL — lightweight URL wrapper with pathname helpers // --------------------------------------------------------------------------- +export interface NextURLConfig { + basePath?: string; + nextConfig?: { + i18n?: { + locales: string[]; + defaultLocale: string; + }; + }; +} + export class NextURL { + /** Internal URL stores the pathname WITHOUT basePath or locale prefix. */ private _url: URL; + private _basePath: string; + private _locale: string | undefined; + private _defaultLocale: string | undefined; + private _locales: string[] | undefined; - constructor(input: string | URL, base?: string | URL) { + constructor(input: string | URL, base?: string | URL, config?: NextURLConfig) { this._url = new URL(input.toString(), base); + this._basePath = config?.basePath ?? ""; + this._stripBasePath(); + const i18n = config?.nextConfig?.i18n; + if (i18n) { + this._locales = [...i18n.locales]; + this._defaultLocale = i18n.defaultLocale; + this._analyzeLocale(this._locales); + } + } + + /** Strip basePath prefix from the internal pathname. */ + private _stripBasePath(): void { + if (!this._basePath) return; + const { pathname } = this._url; + if (pathname === this._basePath || pathname.startsWith(this._basePath + "/")) { + this._url.pathname = pathname.slice(this._basePath.length) || "/"; + } + } + + /** Extract locale from pathname, stripping it from the internal URL. */ + private _analyzeLocale(locales: string[]): void { + const segments = this._url.pathname.split("/"); + const candidate = segments[1]?.toLowerCase(); + const match = locales.find((l) => l.toLowerCase() === candidate); + if (match) { + this._locale = match; + this._url.pathname = "/" + segments.slice(2).join("/"); + } else { + this._locale = this._defaultLocale; + } + } + + /** + * Reconstruct the full pathname with basePath + locale prefix. + * Mirrors Next.js's internal formatPathname(). + */ + private _formatPathname(): string { + // Build prefix: basePath + locale (skip defaultLocale — Next.js omits it) + let prefix = this._basePath; + if (this._locale && this._locale !== this._defaultLocale) { + prefix += "/" + this._locale; + } + if (!prefix) return this._url.pathname; + const inner = this._url.pathname; + return inner === "/" ? prefix : prefix + inner; } get href(): string { - return this._url.href; + const formatted = this._formatPathname(); + if (formatted === this._url.pathname) return this._url.href; + // Replace pathname in href via string slicing — avoids URL allocation. + // URL.href is always . + const { href, pathname, search, hash } = this._url; + const baseEnd = href.length - pathname.length - search.length - hash.length; + return href.slice(0, baseEnd) + formatted + search + hash; } set href(value: string) { this._url.href = value; + this._stripBasePath(); + if (this._locales) this._analyzeLocale(this._locales); } get origin(): string { @@ -276,6 +358,7 @@ export class NextURL { this._url.port = value; } + /** Returns the pathname WITHOUT basePath or locale prefix. */ get pathname(): string { return this._url.pathname; } @@ -301,12 +384,53 @@ export class NextURL { this._url.hash = value; } + get basePath(): string { + return this._basePath; + } + set basePath(value: string) { + this._basePath = value === "" ? "" : value.startsWith("/") ? value : "/" + value; + } + + get locale(): string { + return this._locale ?? ""; + } + set locale(value: string | undefined) { + if (this._locales) { + if (!value) { + this._locale = this._defaultLocale; + return; + } + if (!this._locales.includes(value)) { + throw new TypeError( + `The locale "${value}" is not in the configured locales: ${this._locales.join(", ")}`, + ); + } + } + this._locale = this._locales ? value : this._locale; + } + + get defaultLocale(): string | undefined { + return this._defaultLocale; + } + + get locales(): string[] | undefined { + return this._locales ? [...this._locales] : undefined; + } + clone(): NextURL { - return new NextURL(this._url.href); + const config: NextURLConfig = { + basePath: this._basePath, + nextConfig: this._locales + ? { i18n: { locales: [...this._locales], defaultLocale: this._defaultLocale! } } + : undefined, + }; + // Pass the full href (with locale/basePath re-added) so the constructor + // can re-analyze and extract locale correctly. + return new NextURL(this.href, undefined, config); } toString(): string { - return this._url.toString(); + return this.href; } /** diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index aefc14b64..4bd9d29cc 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -16609,7 +16609,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const mwUrl = new URL(request.url); mwUrl.pathname = cleanPathname; const mwRequest = new Request(mwUrl, request); - const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); + const __mwNextConfig = (__basePath || __i18nConfig) ? { basePath: __basePath, i18n: __i18nConfig ?? undefined } : undefined; + const nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest, __mwNextConfig ? { nextConfig: __mwNextConfig } : undefined); const mwFetchEvent = new NextFetchEvent({ page: cleanPathname }); const mwResponse = await middlewareFn(nextRequest, mwFetchEvent); const _mwWaitUntil = mwFetchEvent.drainWaitUntil(); @@ -20099,7 +20100,8 @@ async function _runMiddleware(request) { mwUrl.pathname = normalizedPathname; mwRequest = new Request(mwUrl, request); } - var nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest); + var __mwNextConfig = (vinextConfig.basePath || i18nConfig) ? { basePath: vinextConfig.basePath, i18n: i18nConfig || undefined } : undefined; + var nextRequest = mwRequest instanceof NextRequest ? mwRequest : new NextRequest(mwRequest, __mwNextConfig ? { nextConfig: __mwNextConfig } : undefined); var fetchEvent = new NextFetchEvent({ page: normalizedPathname }); var response; try { response = await middlewareFn(nextRequest, fetchEvent); } diff --git a/tests/shims.test.ts b/tests/shims.test.ts index f83034c2b..c6e3fc0ae 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -3199,7 +3199,7 @@ describe("NextFetchEvent passed to middleware", () => { let receivedEvent: any; const mockRunner = { import: async () => ({ - middleware: (req: any, event: any) => { + middleware: (_req: any, event: any) => { receivedEvent = event; event.waitUntil(Promise.resolve("done")); return new Response(null, { @@ -3996,6 +3996,322 @@ describe("NextRequest API", () => { }); }); +// --------------------------------------------------------------------------- +// NextURL basePath and locale properties + +describe("NextURL basePath and locale properties", () => { + const i18nConfig = { + nextConfig: { + i18n: { + locales: ["en", "fr", "de"], + defaultLocale: "en", + }, + }, + }; + + it("basePath defaults to empty string when no config provided", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/dashboard"); + expect(url.basePath).toBe(""); + }); + + it("basePath returns the configured value", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/dashboard", undefined, { + basePath: "/app", + }); + expect(url.basePath).toBe("/app"); + }); + + it("basePath setter normalizes leading slash", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/dashboard"); + url.basePath = "app"; + expect(url.basePath).toBe("/app"); + }); + + it("basePath is preserved through clone()", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/dashboard", undefined, { + basePath: "/docs", + }); + const cloned = url.clone(); + expect(cloned.basePath).toBe("/docs"); + }); + + it("locale defaults to empty string when no i18n config", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/about"); + expect(url.locale).toBe(""); + expect(url.defaultLocale).toBeUndefined(); + }); + + it("locale returns the detected locale from pathname", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig); + expect(url.locale).toBe("fr"); + expect(url.defaultLocale).toBe("en"); + expect(url.pathname).toBe("/about"); + }); + + it("locale falls back to defaultLocale when no locale in pathname", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/about", undefined, i18nConfig); + expect(url.locale).toBe("en"); + expect(url.pathname).toBe("/about"); + }); + + it("locale detection is case-insensitive", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/FR/about", undefined, i18nConfig); + expect(url.locale).toBe("fr"); + expect(url.pathname).toBe("/about"); + }); + + it("locale setter updates the locale and affects href", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig); + expect(url.locale).toBe("fr"); + url.locale = "de"; + expect(url.locale).toBe("de"); + expect(url.href).toContain("/de/about"); + }); + + it("locale setter throws on invalid locale", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig); + expect(() => { + url.locale = "es"; + }).toThrow(TypeError); + }); + + it("locales returns a copy of the configured locales array", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/about", undefined, i18nConfig); + const locales = url.locales!; + expect(locales).toEqual(["en", "fr", "de"]); + // Mutating the returned array must not affect internals + locales.push("es"); + expect(url.locales).toEqual(["en", "fr", "de"]); + }); + + it("locales returns undefined without i18n config", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/about"); + expect(url.locales).toBeUndefined(); + }); + + // --- href / toString() reconstruction --- + + it("toString() preserves locale prefix in serialized URL", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig); + expect(url.toString()).toBe("http://localhost/fr/about"); + expect(url.href).toBe("http://localhost/fr/about"); + }); + + it("toString() omits defaultLocale prefix (matches Next.js)", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/about", undefined, i18nConfig); + expect(url.locale).toBe("en"); // defaultLocale + expect(url.toString()).toBe("http://localhost/about"); + }); + + it("setting locale changes the serialized href", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig); + url.locale = "de"; + expect(url.href).toBe("http://localhost/de/about"); + }); + + it("href includes basePath prefix", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/dashboard", undefined, { + basePath: "/app", + }); + expect(url.pathname).toBe("/dashboard"); + expect(url.href).toBe("http://localhost/app/dashboard"); + }); + + it("href includes both basePath and locale prefix", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/fr/about", undefined, { + basePath: "/app", + ...i18nConfig, + }); + expect(url.pathname).toBe("/about"); + expect(url.href).toBe("http://localhost/app/fr/about"); + }); + + it("href preserves port, search, and hash when basePath is active", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost:3000/app/dashboard?q=1#top", undefined, { + basePath: "/app", + }); + expect(url.pathname).toBe("/dashboard"); + expect(url.href).toBe("http://localhost:3000/app/dashboard?q=1#top"); + }); + + it("root locale path /fr produces pathname /", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/fr", undefined, i18nConfig); + expect(url.locale).toBe("fr"); + expect(url.pathname).toBe("/"); + expect(url.href).toBe("http://localhost/fr"); + }); + + it("href setter re-analyzes locale", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig); + expect(url.locale).toBe("fr"); + url.href = "http://localhost/de/contact"; + expect(url.locale).toBe("de"); + expect(url.pathname).toBe("/contact"); + }); + + it("href setter re-strips basePath before locale analysis", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/app/fr/about", undefined, { + basePath: "/app", + ...i18nConfig, + }); + url.href = "http://localhost/app/de/contact"; + expect(url.locale).toBe("de"); + expect(url.pathname).toBe("/contact"); + expect(url.basePath).toBe("/app"); + }); + + it("basePath setter to empty string clears basePath", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/dashboard", undefined, { + basePath: "/app", + }); + expect(url.basePath).toBe("/app"); + url.basePath = ""; + expect(url.basePath).toBe(""); + expect(url.href).toBe("http://localhost/dashboard"); + }); + + it("basePath root path has no trailing slash", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/app", undefined, { + basePath: "/app", + }); + expect(url.pathname).toBe("/"); + expect(url.href).toBe("http://localhost/app"); + }); + + it("basePath is stripped from input URL (basePath-only, no i18n)", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/app/dashboard", undefined, { + basePath: "/app", + }); + expect(url.pathname).toBe("/dashboard"); + expect(url.basePath).toBe("/app"); + expect(url.href).toBe("http://localhost/app/dashboard"); + }); + + it("pathname setter does not re-analyze locale", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig); + url.pathname = "/contact"; + expect(url.locale).toBe("fr"); // unchanged + expect(url.pathname).toBe("/contact"); + expect(url.href).toBe("http://localhost/fr/contact"); + }); + + it("basePath root path with default locale has no trailing slash", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/app", undefined, { + basePath: "/app", + ...i18nConfig, + }); + expect(url.locale).toBe("en"); // default locale, no prefix in output + expect(url.pathname).toBe("/"); + expect(url.href).toBe("http://localhost/app"); + }); + + it("locale setter resets to defaultLocale when set to undefined with i18n", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/fr/about", undefined, i18nConfig); + expect(url.locale).toBe("fr"); + url.locale = undefined; + expect(url.locale).toBe("en"); // falls back to defaultLocale + expect(url.href).toBe("http://localhost/about"); // default locale omitted from prefix + }); + + it("locale setter resets to defaultLocale when set to empty string with i18n", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/de/contact", undefined, i18nConfig); + url.locale = ""; + expect(url.locale).toBe("en"); + }); + + it("searchParams mutations are reflected in href with basePath and locale", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/fr/about", undefined, { + basePath: "/app", + ...i18nConfig, + }); + url.searchParams.set("q", "2"); + expect(url.href).toBe("http://localhost/app/fr/about?q=2"); + }); + + // --- clone() --- + + it("clone() preserves locale, basePath, and config through constructor", async () => { + const { NextURL } = await import("../packages/vinext/src/shims/server.js"); + const url = new NextURL("http://localhost/fr/about", undefined, { + basePath: "/app", + ...i18nConfig, + }); + const cloned = url.clone(); + expect(cloned.basePath).toBe("/app"); + expect(cloned.locale).toBe("fr"); + expect(cloned.defaultLocale).toBe("en"); + expect(cloned.pathname).toBe("/about"); + expect(cloned.href).toBe("http://localhost/app/fr/about"); + // Mutations on clone don't affect original + cloned.locale = "de"; + expect(url.locale).toBe("fr"); + }); + + // --- NextRequest integration --- + + it("NextRequest passes basePath and i18n config through to nextUrl", async () => { + const { NextRequest } = await import("../packages/vinext/src/shims/server.js"); + const req = new NextRequest("http://localhost/fr/dashboard", { + nextConfig: { + basePath: "/app", + i18n: { + locales: ["en", "fr"], + defaultLocale: "en", + }, + }, + }); + expect(req.nextUrl.basePath).toBe("/app"); + expect(req.nextUrl.locale).toBe("fr"); + expect(req.nextUrl.defaultLocale).toBe("en"); + expect(req.nextUrl.pathname).toBe("/dashboard"); + expect(req.nextUrl.href).toBe("http://localhost/app/fr/dashboard"); + }); + + it("NextRequest passes config when input is a Request object", async () => { + const { NextRequest } = await import("../packages/vinext/src/shims/server.js"); + const raw = new Request("http://localhost/app/fr/dashboard"); + const req = new NextRequest(raw, { + nextConfig: { + basePath: "/app", + i18n: { locales: ["en", "fr"], defaultLocale: "en" }, + }, + }); + expect(req.nextUrl.basePath).toBe("/app"); + expect(req.nextUrl.locale).toBe("fr"); + expect(req.nextUrl.pathname).toBe("/dashboard"); + expect(req.nextUrl.href).toBe("http://localhost/app/fr/dashboard"); + }); +}); + // --------------------------------------------------------------------------- // NextResponse.next() with request header forwarding