diff --git a/packages/fresh/src/app.ts b/packages/fresh/src/app.ts index fe1f740fb87..2031ee643e4 100644 --- a/packages/fresh/src/app.ts +++ b/packages/fresh/src/app.ts @@ -2,6 +2,7 @@ import { trace } from "@opentelemetry/api"; import { DENO_DEPLOYMENT_ID } from "@fresh/build-id"; import * as colors from "@std/fmt/colors"; +import { setBasePath } from "./runtime/shared.ts"; import type { MaybeLazyMiddleware, Middleware } from "./middlewares/mod.ts"; import { Context } from "./context.ts"; import { mergePath, type Method, UrlPatternRouter } from "./router.ts"; @@ -186,6 +187,10 @@ export class App { config: ResolvedFreshConfig; constructor(config: FreshConfig = {}) { + if (config.basePath !== undefined) { + this.#validateBasePath(config.basePath); + } + this.config = { root: ".", basePath: config.basePath ?? "", @@ -193,6 +198,38 @@ export class App { }; } + #validateBasePath(basePath: string): void { + if (basePath === "" || basePath === "/") { + return; + } + + if (!basePath.startsWith("/")) { + throw new Error( + `Invalid basePath: "${basePath}". Must be empty, "/" or start with "/"`, + ); + } + + if (basePath.endsWith("/")) { + throw new Error( + `Invalid basePath: "${basePath}". Must not end with "/" except for root path`, + ); + } + + // Validate by round-tripping through URL — catches all invalid path chars + try { + const url = new URL(basePath, "https://localhost"); + if (url.pathname !== basePath) { + throw new Error( + `Invalid basePath: "${basePath}". Contains characters that require encoding`, + ); + } + } catch { + throw new Error( + `Invalid basePath: "${basePath}". Must be a valid URL path segment`, + ); + } + } + /** * Add one or more middlewares at the top or the specified path. */ @@ -336,10 +373,16 @@ export class App { const cmd = app.#commands[i]; if (cmd.type !== CommandType.App && cmd.type !== CommandType.NotFound) { - // Apply the inner app's basePath if it exists let effectivePattern = cmd.pattern; if (app.config.basePath) { - effectivePattern = mergePath(app.config.basePath, cmd.pattern, false); + // Avoid double basePath when mount path equals inner app's basePath + if (path !== app.config.basePath) { + effectivePattern = mergePath( + app.config.basePath, + cmd.pattern, + false, + ); + } } const clone = { @@ -383,6 +426,8 @@ export class App { } } + setBasePath(this.config.basePath); + const router = new UrlPatternRouter>(); const { rootHandler } = applyCommands( diff --git a/packages/fresh/src/app_test.tsx b/packages/fresh/src/app_test.tsx index 67563b9f469..4f00ab49898 100644 --- a/packages/fresh/src/app_test.tsx +++ b/packages/fresh/src/app_test.tsx @@ -959,3 +959,24 @@ Deno.test("App - .mountApp() with both main and inner basePath", async () => { res = await server.get("/main/services/users"); expect(res.status).toEqual(404); }); + +Deno.test("App - .mountApp() avoids double basePath when mounting at same path as inner basePath", async () => { + const innerApp = new App({ basePath: "/ui" }) + .get("/", () => new Response("ui home")) + .get("/dashboard", () => new Response("dashboard")); + + const app = new App() + .get("/", () => new Response("root home")) + .mountApp("/ui", innerApp); + + const server = new FakeServer(app.handler()); + + let res = await server.get("/"); + expect(await res.text()).toEqual("root home"); + + res = await server.get("/ui"); + expect(await res.text()).toEqual("ui home"); + + res = await server.get("/ui/dashboard"); + expect(await res.text()).toEqual("dashboard"); +}); diff --git a/packages/fresh/src/runtime/server/preact_hooks.ts b/packages/fresh/src/runtime/server/preact_hooks.ts index d33d61fee1a..ec6cb58d9ce 100644 --- a/packages/fresh/src/runtime/server/preact_hooks.ts +++ b/packages/fresh/src/runtime/server/preact_hooks.ts @@ -115,7 +115,7 @@ options[OptionsType.VNODE] = (vnode) => { setActiveUrl(vnode, RENDER_STATE.ctx.url.pathname); } } - assetHashingHook(vnode, BUILD_ID); + assetHashingHook(vnode, BUILD_ID, RENDER_STATE?.ctx.config.basePath); if (typeof vnode.type === "function") { if (vnode.type === Partial) { @@ -301,8 +301,11 @@ options[OptionsType.DIFF] = (vnode) => { if (id.endsWith(".css")) { items.push( - // deno-lint-ignore no-explicit-any - h("link", { rel: "stylesheet", href: asset(id) } as any), + h( + "link", + // deno-lint-ignore no-explicit-any + { rel: "stylesheet", href: asset(id) } as any, + ), ); } } @@ -470,14 +473,15 @@ function RemainingHead() { RENDER_STATE.islands.forEach((island) => { if (island.css.length > 0) { for (let i = 0; i < island.css.length; i++) { - const css = island.css[i]; - items.push(h("link", { rel: "stylesheet", href: css })); + items.push( + h("link", { rel: "stylesheet", href: asset(island.css[i]) }), + ); } } }); RENDER_STATE.islandAssets.forEach((css) => { - items.push(h("link", { rel: "stylesheet", href: css })); + items.push(h("link", { rel: "stylesheet", href: asset(css) })); }); if (items.length > 0) { diff --git a/packages/fresh/src/runtime/shared.ts b/packages/fresh/src/runtime/shared.ts index 6e05fed7d71..38e9aecc85d 100644 --- a/packages/fresh/src/runtime/shared.ts +++ b/packages/fresh/src/runtime/shared.ts @@ -2,6 +2,8 @@ import type { ComponentChildren, VNode } from "preact"; import { BUILD_ID } from "@fresh/build-id"; import { assetInternal, assetSrcSetInternal } from "./shared_internal.ts"; +let BASE_PATH = ""; + export { HttpError } from "../error.ts"; /** @@ -22,18 +24,23 @@ export { HttpError } from "../error.ts"; */ export const IS_BROWSER = typeof document !== "undefined"; +/** @internal Set the base path for asset URLs. Called once during app init. */ +export function setBasePath(basePath: string) { + BASE_PATH = basePath; +} + /** * Create a "locked" asset path. This differs from a plain path in that it is * specific to the current version of the application, and as such can be safely * served with a very long cache lifetime (1 year). */ export function asset(path: string): string { - return assetInternal(path, BUILD_ID); + return assetInternal(path, BUILD_ID, BASE_PATH); } /** Apply the `asset` function to urls in a `srcset` attribute. */ export function assetSrcSet(srcset: string): string { - return assetSrcSetInternal(srcset, BUILD_ID); + return assetSrcSetInternal(srcset, BUILD_ID, BASE_PATH); } export interface PartialProps { diff --git a/packages/fresh/src/runtime/shared_internal.ts b/packages/fresh/src/runtime/shared_internal.ts index d91d6e642ed..8d1cc880447 100644 --- a/packages/fresh/src/runtime/shared_internal.ts +++ b/packages/fresh/src/runtime/shared_internal.ts @@ -91,7 +91,11 @@ export const enum PartialMode { * specific to the current version of the application, and as such can be safely * served with a very long cache lifetime (1 year). */ -export function assetInternal(path: string, buildId: string): string { +export function assetInternal( + path: string, + buildId: string, + basePath?: string, +): string { if (!path.startsWith("/") || path.startsWith("//")) return path; try { const url = new URL(path, "https://freshassetcache.local"); @@ -102,7 +106,11 @@ export function assetInternal(path: string, buildId: string): string { return path; } url.searchParams.set(ASSET_CACHE_BUST_KEY, buildId); - return url.pathname + url.search + url.hash; + let finalPath = url.pathname + url.search + url.hash; + + finalPath = applyBasePath(finalPath, basePath); + + return finalPath; } catch (err) { // deno-lint-ignore no-console console.warn( @@ -114,7 +122,11 @@ export function assetInternal(path: string, buildId: string): string { } /** Apply the `asset` function to urls in a `srcset` attribute. */ -export function assetSrcSetInternal(srcset: string, buildId: string): string { +export function assetSrcSetInternal( + srcset: string, + buildId: string, + basePath?: string, +): string { if (srcset.includes("(")) return srcset; // Bail if the srcset contains complicated syntax. const parts = srcset.split(","); const constructed = []; @@ -127,7 +139,9 @@ export function assetSrcSetInternal(srcset: string, buildId: string): string { const leading = part.substring(0, leadingWhitespace); const url = trimmed.substring(0, urlEnd); const trailing = trimmed.substring(urlEnd); - constructed.push(leading + assetInternal(url, buildId) + trailing); + constructed.push( + leading + assetInternal(url, buildId, basePath) + trailing, + ); } return constructed.join(","); } @@ -139,15 +153,34 @@ export function assetHashingHook( ["data-fresh-disable-lock"]?: boolean; }>, buildId: string, + basePath?: string, ) { if (vnode.type === "img" || vnode.type === "source") { const { props } = vnode; if (props["data-fresh-disable-lock"]) return; if (typeof props.src === "string") { - props.src = assetInternal(props.src, buildId); + props.src = assetInternal(props.src, buildId, basePath); } if (typeof props.srcset === "string") { - props.srcset = assetSrcSetInternal(props.srcset, buildId); + props.srcset = assetSrcSetInternal(props.srcset, buildId, basePath); } } } + +/** Apply basePath to a given path string */ +export function applyBasePath(path: string, basePath?: string): string { + if (!basePath || basePath === "/") { + return path; + } + + if (!path.startsWith("/") || path.startsWith("//")) { + return path; + } + + // Avoid double-prefixing if the path already starts with basePath + if (path.startsWith(basePath + "/") || path === basePath) { + return path; + } + + return basePath + path; +} diff --git a/packages/fresh/tests/basepath_test.tsx b/packages/fresh/tests/basepath_test.tsx new file mode 100644 index 00000000000..280c4aff46d --- /dev/null +++ b/packages/fresh/tests/basepath_test.tsx @@ -0,0 +1,66 @@ +import { expect } from "@std/expect"; +import { App } from "fresh"; +import { applyBasePath } from "../src/runtime/shared_internal.ts"; + +Deno.test("basePath validation - rejects invalid paths", () => { + expect(() => new App({ basePath: "invalid" })).toThrow( + 'Invalid basePath: "invalid". Must be empty, "/" or start with "/"', + ); + + expect(() => new App({ basePath: "/ui/" })).toThrow( + 'Invalid basePath: "/ui/". Must not end with "/" except for root path', + ); + + expect(() => new App({ basePath: "/ui admin" })).toThrow( + 'Invalid basePath: "/ui admin"', + ); +}); + +Deno.test("basePath validation - accepts valid paths", () => { + expect(() => new App({ basePath: "" })).not.toThrow(); + expect(() => new App({ basePath: "/" })).not.toThrow(); + expect(() => new App({ basePath: "/ui" })).not.toThrow(); + expect(() => new App({ basePath: "/api/v1" })).not.toThrow(); + expect(() => new App({ basePath: "/ui-admin" })).not.toThrow(); + expect(() => new App({ basePath: "/ui.test" })).not.toThrow(); + expect(() => new App({ basePath: "/deep/nested/path" })).not.toThrow(); +}); + +Deno.test("basePath validation - rejects relative paths", () => { + expect(() => new App({ basePath: "./" })).toThrow( + 'Invalid basePath: "./". Must be empty, "/" or start with "/"', + ); +}); +Deno.test("applyBasePath - no basePath", () => { + expect(applyBasePath("/test", undefined)).toBe("/test"); + expect(applyBasePath("/test", "")).toBe("/test"); + expect(applyBasePath("/test", "/")).toBe("/test"); +}); + +Deno.test("applyBasePath - relative paths not affected", () => { + expect(applyBasePath("test", "/ui")).toBe("test"); + expect(applyBasePath("./test", "/ui")).toBe("./test"); + expect(applyBasePath("../test", "/ui")).toBe("../test"); +}); + +Deno.test("applyBasePath - absolute basePath", () => { + expect(applyBasePath("/test", "/ui")).toBe("/ui/test"); + expect(applyBasePath("/api/users", "/ui")).toBe("/ui/api/users"); + expect(applyBasePath("/", "/ui")).toBe("/ui/"); +}); + +Deno.test("applyBasePath - complex paths", () => { + expect(applyBasePath("/api/v1/users", "/app")).toBe("/app/api/v1/users"); + expect(applyBasePath("/assets/style.css", "/ui/admin")).toBe( + "/ui/admin/assets/style.css", + ); +}); + +Deno.test("applyBasePath - non-absolute paths", () => { + expect(applyBasePath("http://example.com/test", "/ui")).toBe( + "http://example.com/test", + ); + expect(applyBasePath("//cdn.example.com/test", "/ui")).toBe( + "//cdn.example.com/test", + ); +}); diff --git a/packages/plugin-vite/tests/build_test.ts b/packages/plugin-vite/tests/build_test.ts index 0416c9f87f7..3cec7128526 100644 --- a/packages/plugin-vite/tests/build_test.ts +++ b/packages/plugin-vite/tests/build_test.ts @@ -726,3 +726,47 @@ Deno.test({ sanitizeOps: false, sanitizeResources: false, }); + +Deno.test({ + name: "vite build - basePath asset links are correctly prefixed", + fn: async () => { + await using res = await buildVite(DEMO_DIR, { base: "/ui/" }); + + await launchProd( + { cwd: res.tmp }, + async (address) => { + await withBrowser(async (page) => { + await page.goto(`${address}/ui/tests/css_modules`, { + waitUntil: "networkidle2", + }); + + // Test CSS links are prefixed correctly + const stylesheetHrefs = await page.evaluate(() => { + const links = Array.from( + document.querySelectorAll('link[rel="stylesheet"]'), + ); + return links.map((link) => (link as HTMLLinkElement).href); + }); + + stylesheetHrefs.forEach((href) => { + expect(href).toMatch(/\/ui\/assets\/.*\.css/); + }); + + // Test image links are prefixed correctly + const imageSrcs = await page.evaluate(() => { + const images = Array.from(document.querySelectorAll("img")); + return images.map((img) => img.src).filter((src) => + src.includes("/assets/") + ); + }); + + imageSrcs.forEach((src) => { + expect(src).toMatch(/\/ui\/assets\//); + }); + }); + }, + ); + }, + sanitizeOps: false, + sanitizeResources: false, +});