diff --git a/src/routing.ts b/src/routing.ts index 2cc9421eeb..73e4deea4a 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -7,6 +7,7 @@ import { runtimeDir } from "nitro/meta"; import { addRoute, createRouter, findRoute, findAllRoutes } from "rou3"; import { compileRouterToString } from "rou3/compiler"; import { hash } from "ohash"; +import { encodeNonAsciiRoute } from "./utils/route.ts"; const isGlobalMiddleware = (h: NitroEventHandler) => !h.method && (!h.route || h.route === "/**"); @@ -157,7 +158,12 @@ export class Router { this._router = createRouter(); this._compiled = undefined; for (const route of routes) { - addRoute(this._router, route.method, this._baseURL + route.route, route.data); + addRoute( + this._router, + route.method, + this._baseURL + encodeNonAsciiRoute(route.route), + route.data + ); } if (opts?.merge) { mergeCatchAll(this._router); diff --git a/src/utils/route.ts b/src/utils/route.ts new file mode 100644 index 0000000000..9b6c529ce5 --- /dev/null +++ b/src/utils/route.ts @@ -0,0 +1,16 @@ +// Percent-encodes the non-ASCII characters in a route pattern, leaving the rest untouched. +export function encodeNonAsciiRoute(route: string): string { + let hasNonAscii = false; + for (let i = 0; i < route.length; i++) { + if (route.charCodeAt(i) > 127) { + hasNonAscii = true; + break; + } + } + if (!hasNonAscii) { + return route; + } + return Array.from(route) + .map((char) => (char.codePointAt(0)! > 127 ? encodeURIComponent(char) : char)) + .join(""); +} diff --git a/test/vite/nonascii-route-fixture/nitro.config.ts b/test/vite/nonascii-route-fixture/nitro.config.ts new file mode 100644 index 0000000000..943c4ac23d --- /dev/null +++ b/test/vite/nonascii-route-fixture/nitro.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "nitro"; + +export default defineConfig({ + preset: "static", + prerender: { routes: ["/について"] }, + // Points to an ASCII-named handler file; the route pattern itself is the literal + // non-ASCII text. + handlers: [{ route: "/について", handler: "./routes/nonascii.ts", method: "GET" }], +}); diff --git a/test/vite/nonascii-route-fixture/routes/nonascii.ts b/test/vite/nonascii-route-fixture/routes/nonascii.ts new file mode 100644 index 0000000000..4994d62cce --- /dev/null +++ b/test/vite/nonascii-route-fixture/routes/nonascii.ts @@ -0,0 +1 @@ +export default () => "

ok

"; diff --git a/test/vite/nonascii-route-fixture/vite.config.ts b/test/vite/nonascii-route-fixture/vite.config.ts new file mode 100644 index 0000000000..dc884054e6 --- /dev/null +++ b/test/vite/nonascii-route-fixture/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import { nitro } from "nitro/vite"; + +export default defineConfig({ + plugins: [nitro()], +}); diff --git a/test/vite/nonascii-route.test.ts b/test/vite/nonascii-route.test.ts new file mode 100644 index 0000000000..742fe8d036 --- /dev/null +++ b/test/vite/nonascii-route.test.ts @@ -0,0 +1,63 @@ +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { rm, mkdir, readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { createNitro, build, prepare } from "nitro/builder"; + +const fixtureDir = fileURLToPath(new URL("./nonascii-route-fixture", import.meta.url)); +const tmpDir = fileURLToPath(new URL("./nonascii-route-fixture/.tmp", import.meta.url)); + +// A route path written in Japanese. Registered via `handlers` in the fixture's +// nitro.config.ts so the route pattern stays literal Unicode without needing a +// non-ASCII filename in the repo. +const route = "/について"; + +describe("non-ASCII route paths", () => { + it("is prerendered under its own literal path (static preset)", async () => { + const outDir = join(tmpDir, "static"); + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + const nitro = await createNitro({ + rootDir: fixtureDir, + preset: "static", + output: { dir: outDir }, + builder: "vite", + }); + try { + await prepare(nitro); + await build(nitro); + } finally { + await nitro.close(); + } + + // Reads the file prerendered at the route's own literal path. + const html = await readFile(join(outDir, "public", "について"), "utf8"); + expect(html).toBe("

ok

"); + }, 30_000); + + it("is reachable on a running server", async () => { + const outDir = join(tmpDir, "standard"); + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + const nitro = await createNitro({ + rootDir: fixtureDir, + preset: "standard", + output: { dir: outDir }, + builder: "vite", + }); + try { + await prepare(nitro); + await build(nitro); + } finally { + await nitro.close(); + } + + const entry = join(outDir, "server/index.mjs"); + const { fetch } = await import(entry).then((m) => m.default); + + // Requests the literal Unicode path, exactly as a browser navigation would send it. + const response = await fetch(new Request(`http://localhost${route}`)); + expect(response.status).toBe(200); + expect(await response.text()).toBe("

ok

"); + }, 30_000); +});