-
Notifications
You must be signed in to change notification settings - Fork 247
fix: add basePath and locale properties to NextURL #516
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
79a24b0
93e8084
81e258d
46ec93f
a365cf8
57b8534
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -394,6 +394,7 @@ export async function runMiddleware( | |
| middlewarePath: string, | ||
| request: Request, | ||
| i18nConfig?: NextI18nConfig | null, | ||
| basePath?: string, | ||
| ): Promise<MiddlewareResult> { | ||
| // 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 } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: The parallel code in |
||
| : undefined; | ||
| const nextRequest = | ||
| mwRequest instanceof NextRequest | ||
| ? mwRequest | ||
| : new NextRequest(mwRequest, nextConfig ? { nextConfig } : undefined); | ||
| const fetchEvent = new NextFetchEvent({ page: normalizedPathname }); | ||
|
|
||
| // Execute the middleware | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 ?? {}; | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good fix — stripping Minor note: when |
||||||||||||||||||||
| // 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,18 +81,21 @@ 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" | ||||||||||||||||||||
| ? new URL(input, "http://localhost") | ||||||||||||||||||||
| : 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); | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Not blocking — just flagging for awareness. |
||||||||||||||||||||
| 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("/"); | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: when |
||||||||||||||||||||
| } else { | ||||||||||||||||||||
| this._locale = this._defaultLocale; | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /** | ||||||||||||||||||||
| * Reconstruct the full pathname with basePath + locale prefix. | ||||||||||||||||||||
| * Mirrors Next.js's internal formatPathname(). | ||||||||||||||||||||
| */ | ||||||||||||||||||||
| private _formatPathname(): string { | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth noting as a follow-up: Next.js supports a |
||||||||||||||||||||
| // 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; | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: the However, if someone calls |
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| get href(): string { | ||||||||||||||||||||
| return this._url.href; | ||||||||||||||||||||
| const formatted = this._formatPathname(); | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance nit: this allocates a new A simple optimization would be to cache the formatted href and invalidate it when |
||||||||||||||||||||
| if (formatted === this._url.pathname) return this._url.href; | ||||||||||||||||||||
| // Replace pathname in href via string slicing — avoids URL allocation. | ||||||||||||||||||||
| // URL.href is always <origin+auth><pathname><search><hash>. | ||||||||||||||||||||
| const { href, pathname, search, hash } = this._url; | ||||||||||||||||||||
| const baseEnd = href.length - pathname.length - search.length - hash.length; | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The string-slicing optimization to avoid One edge case to be aware of for future work: if the URL has a |
||||||||||||||||||||
| 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); | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The The existing test avoids this by only setting Consider adding basePath stripping before locale analysis here:
Suggested change
And add a test case like: it("href setter re-strips basePath and re-analyzes locale", 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");
});
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| get origin(): string { | ||||||||||||||||||||
|
|
@@ -276,6 +358,7 @@ export class NextURL { | |||||||||||||||||||
| this._url.port = value; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /** Returns the pathname WITHOUT basePath or locale prefix. */ | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The In Next.js, the it("pathname setter does not re-analyze locale", async () => {
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");
}); |
||||||||||||||||||||
| 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; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /** | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: when
nextConfig.basePathisundefined,__basePathisundefined, so{ basePath: __basePath, ... }produces{ basePath: undefined, ... }. TheNextURLconstructor handles this correctly viaconfig?.basePath ?? "". Just confirming this works as intended — it does.