Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b102aa7
fix: basePath handling for CSS and img links
fry69 Sep 13, 2025
310917a
test: add missing img assets test for basePath
fry69 Sep 13, 2025
27cba37
test: add hono testcase with basepath
fry69 Sep 13, 2025
f7051f8
chore: add hono package to plugin-vite
fry69 Sep 13, 2025
e0b805c
test: expand hono test to acutally use hono
fry69 Sep 13, 2025
df01dd2
test: use actual request to check hono integration
fry69 Sep 13, 2025
11cab06
test: improve integration smoke test for hono
fry69 Sep 13, 2025
2b3644e
test: clarifying relative basePath tests
fry69 Sep 13, 2025
a1110d5
test: fix missing fixtures
fry69 Sep 13, 2025
6dde40d
fix: cleanup
fry69 Sep 13, 2025
2e13912
chore: remove chatty comments
fry69 Sep 13, 2025
a948739
fix: remove relative basePath support
fry69 Sep 13, 2025
c99ed3c
chore: tiny revert to remove commit noise
fry69 Sep 13, 2025
71af5b7
fix: replace unreadable regex with human friendly one
fry69 Sep 13, 2025
ba8cff7
test: cleanup overly complex tests
fry69 Sep 13, 2025
860455b
test: remove unnecessary test
fry69 Sep 13, 2025
440ee67
test: minor cleanup
fry69 Sep 13, 2025
3780646
test: more cleanup
fry69 Sep 13, 2025
b49111d
chore: tiny irrelevant revert
fry69 Sep 13, 2025
a2ba4ae
tests: remove duplicate test
fry69 Sep 15, 2025
9c0da6c
Merge branch 'main' into fry69/fix-basepath
fry69 Sep 15, 2025
fb17c14
Merge remote-tracking branch 'origin/main' into fry69/fix-basepath
bartlomieju Mar 29, 2026
85f93e0
fix: address basePath review feedback
bartlomieju Mar 29, 2026
2382dc4
Merge remote-tracking branch 'origin/main' into fry69/fix-basepath
bartlomieju Mar 29, 2026
8e47a55
fix: update basePath validation test for URL round-trip check
bartlomieju Mar 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions packages/fresh/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -186,13 +187,49 @@ export class App<State> {
config: ResolvedFreshConfig;

constructor(config: FreshConfig = {}) {
if (config.basePath !== undefined) {
this.#validateBasePath(config.basePath);
}

this.config = {
root: ".",
basePath: config.basePath ?? "",
mode: config.mode ?? "production",
};
}

#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.
*/
Expand Down Expand Up @@ -336,10 +373,16 @@ export class App<State> {
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 = {
Expand Down Expand Up @@ -383,6 +426,8 @@ export class App<State> {
}
}

setBasePath(this.config.basePath);

const router = new UrlPatternRouter<Middleware<State>>();

const { rootHandler } = applyCommands(
Expand Down
21 changes: 21 additions & 0 deletions packages/fresh/src/app_test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
16 changes: 10 additions & 6 deletions packages/fresh/src/runtime/server/preact_hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
),
);
}
}
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 9 additions & 2 deletions packages/fresh/src/runtime/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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 {
Expand Down
45 changes: 39 additions & 6 deletions packages/fresh/src/runtime/shared_internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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(
Expand All @@ -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 = [];
Expand All @@ -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(",");
}
Expand All @@ -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;
}
66 changes: 66 additions & 0 deletions packages/fresh/tests/basepath_test.tsx
Original file line number Diff line number Diff line change
@@ -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",
);
});
44 changes: 44 additions & 0 deletions packages/plugin-vite/tests/build_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Loading