From 5bb73d694384fa3e53e076b8a27cfd520da4a9a4 Mon Sep 17 00:00:00 2001 From: Alexey Zakhlestin Date: Fri, 11 Sep 2026 01:16:12 +0100 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=90=9B=20Keep=20the=20URL=20scheme=20?= =?UTF-8?q?in=20SSR=20asset=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite's base may be a full URL. pathe's join, used since #2305 to prefix asset paths with it, folded its "://" into ":/", so every entry, stylesheet, modulepreload and serialized manifest path came out as https:/… --- .../src/server/manifest/dev-ssr-manifest.ts | 1 + .../server/manifest/prod-ssr-manifest.spec.ts | 44 +++++++++++++++++++ .../src/server/manifest/prod-ssr-manifest.ts | 12 +++-- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/start/src/server/manifest/dev-ssr-manifest.ts b/packages/start/src/server/manifest/dev-ssr-manifest.ts index 8e98f0bb0..5b365780f 100644 --- a/packages/start/src/server/manifest/dev-ssr-manifest.ts +++ b/packages/start/src/server/manifest/dev-ssr-manifest.ts @@ -2,6 +2,7 @@ import { join, normalize } from "pathe"; export function getSsrDevManifest(environment: "client" | "ssr") { return { + // Vite reduces an external base to its path in dev, so BASE_URL is always a path here path: (id: string) => normalize(join(import.meta.env.BASE_URL, id)), async getAssets(id) { const assetsPath = `/@manifest/${environment}/${Date.now()}/assets?id=${encodeURIComponent(id)}`; diff --git a/packages/start/src/server/manifest/prod-ssr-manifest.spec.ts b/packages/start/src/server/manifest/prod-ssr-manifest.spec.ts index 583685f02..40f874f1a 100644 --- a/packages/start/src/server/manifest/prod-ssr-manifest.spec.ts +++ b/packages/start/src/server/manifest/prod-ssr-manifest.spec.ts @@ -80,3 +80,47 @@ describe("getSsrProdManifest", () => { ]); }); }); + +describe("getSsrProdManifest with an external Vite base", () => { + beforeEach(() => { + vi.stubEnv("BASE_URL", "https://cdn.example.com/some/prefix/"); + }); + + it("keeps the URL scheme in entry paths", () => { + expect(getSsrProdManifest().path("./src/entry-client.tsx")).toBe( + "https://cdn.example.com/some/prefix/_build/assets/entry-client.js", + ); + }); + + it("keeps the URL scheme in stylesheet and modulepreload URLs", async () => { + await expect(getSsrProdManifest().getAssets("./src/entry-client.tsx")).resolves.toMatchObject([ + { attrs: { href: "https://cdn.example.com/some/prefix/_build/assets/entry-client.css" } }, + { attrs: { href: "https://cdn.example.com/some/prefix/_build/assets/shared.js" } }, + { attrs: { href: "https://cdn.example.com/some/prefix/_build/assets/entry-client.js" } }, + ]); + }); + + it("keeps the URL scheme in serialized manifest paths", async () => { + await expect(getSsrProdManifest().json()).resolves.toMatchObject({ + "src/entry-client.tsx": { + output: "https://cdn.example.com/some/prefix/_build/assets/entry-client.js", + }, + }); + }); + + it("accepts a base without a trailing slash", () => { + vi.stubEnv("BASE_URL", "https://cdn.example.com"); + + expect(getSsrProdManifest().path("./src/entry-client.tsx")).toBe( + "https://cdn.example.com/_build/assets/entry-client.js", + ); + }); + + it("keeps a protocol-relative base", () => { + vi.stubEnv("BASE_URL", "//cdn.example.com/"); + + expect(getSsrProdManifest().path("./src/entry-client.tsx")).toBe( + "//cdn.example.com/_build/assets/entry-client.js", + ); + }); +}); diff --git a/packages/start/src/server/manifest/prod-ssr-manifest.ts b/packages/start/src/server/manifest/prod-ssr-manifest.ts index 597adc788..54b5170d7 100644 --- a/packages/start/src/server/manifest/prod-ssr-manifest.ts +++ b/packages/start/src/server/manifest/prod-ssr-manifest.ts @@ -1,8 +1,12 @@ import { clientViteManifest } from "solid-start:client-vite-manifest"; -import { join } from "pathe"; import { Manifest } from "vite"; import type { Asset } from "../assets/render.tsx"; +// Vite's base may be a full URL. pathe's join would fold its "://" into ":/". +function joinBase(base: string, path: string) { + return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`; +} + // Only reads from client manifest atm, might need server support for islands export function getSsrProdManifest() { const viteManifest = clientViteManifest; @@ -13,7 +17,7 @@ export function getSsrProdManifest() { const viteManifestEntry = clientViteManifest[id /*import.meta.env.START_CLIENT_ENTRY*/]; if (!viteManifestEntry) throw new Error(`No entry found in vite manifest for '${id}'`); - return join(import.meta.env.BASE_URL, viteManifestEntry.file); + return joinBase(import.meta.env.BASE_URL, viteManifestEntry.file); }, async getAssets(id) { if (id.startsWith("./")) id = id.slice(2); @@ -29,7 +33,7 @@ export function getSsrProdManifest() { for (const entryKey of entryKeys) { json[entryKey] = { - output: join(import.meta.env.BASE_URL, viteManifest[entryKey]!.file), + output: joinBase(import.meta.env.BASE_URL, viteManifest[entryKey]!.file), assets: await this.getAssets(entryKey), }; } @@ -54,7 +58,7 @@ function createHtmlTagsForAssets(assets: string[]) { .map(asset => ({ tag: "link", attrs: { - href: join(import.meta.env.BASE_URL, asset), + href: joinBase(import.meta.env.BASE_URL, asset), key: asset, ...(asset.endsWith(".css") ? { rel: "stylesheet" } : { rel: "modulepreload" }), }, From f88b04b7e609b36b9008f601c18aba7b2e6a8c65 Mon Sep 17 00:00:00 2001 From: Alexey Zakhlestin Date: Fri, 11 Sep 2026 01:16:12 +0100 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=A7=AA=20Add=20failing=20tests=20for?= =?UTF-8?q?=20the=20app=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a full URL as Vite's base, server functions post to the CDN and no-JS redirects resolve against it. `SERVER_BASE_URL` is the right place for the app base, but it is empty unless `server.baseURL` is set. --- packages/start/src/config/index.spec.ts | 79 +++++++++++++++++++++++++ packages/start/src/fns/client.spec.ts | 41 ++++++++++++- packages/start/src/fns/handler.spec.ts | 42 ++++++++++++- 3 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 packages/start/src/config/index.spec.ts diff --git a/packages/start/src/config/index.spec.ts b/packages/start/src/config/index.spec.ts new file mode 100644 index 000000000..9e387a382 --- /dev/null +++ b/packages/start/src/config/index.spec.ts @@ -0,0 +1,79 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ConfigEnv, Plugin, UserConfig } from "vite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { solidStart } from "./index.ts"; + +let root: string; + +beforeEach(() => { + root = realpathSync.native(mkdtempSync(join(tmpdir(), "solid-start-config-"))); + mkdirSync(join(root, "src/routes"), { recursive: true }); + writeFileSync(join(root, "src/app.tsx"), "export default () => null;"); + vi.spyOn(process, "cwd").mockReturnValue(root); + // the build branch of the config hook collects route entries from here + vi.stubGlobal("ROUTERS", { client: { getRoutes: async () => [] } }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + rmSync(root, { recursive: true, force: true }); +}); + +/** The `SERVER_BASE_URL` the `solid-start:config` plugin defines for a user config. */ +async function serverBaseUrl(config: UserConfig, command: ConfigEnv["command"] = "build") { + const plugin = solidStart() + .flat() + .find( + (p): p is Plugin => + !!p && typeof p === "object" && "name" in p && p.name === "solid-start:config", + ); + const hook = plugin!.config as (config: UserConfig, env: ConfigEnv) => Promise; + const mode = command === "build" ? "production" : "development"; + const resolved = await hook(config, { command, mode }); + + return JSON.parse(resolved.define!["import.meta.env.SERVER_BASE_URL"] as string) as string; +} + +describe("SERVER_BASE_URL", () => { + const commands: ConfigEnv["command"][] = ["build", "serve"]; + + it.each(commands)("defaults to the root (%s)", async command => { + await expect(serverBaseUrl({}, command)).resolves.toBe("/"); + }); + + it.each(commands)("follows a path-only Vite base (%s)", async command => { + await expect(serverBaseUrl({ base: "/app/" }, command)).resolves.toBe("/app/"); + }); + + it.each(commands)("adds the leading slash Vite adds (%s)", async command => { + await expect(serverBaseUrl({ base: "app/" }, command)).resolves.toBe("/app/"); + }); + + it.each(commands)("maps a relative Vite base to the root (%s)", async command => { + await expect(serverBaseUrl({ base: "./" }, command)).resolves.toBe("/"); + await expect(serverBaseUrl({ base: "" }, command)).resolves.toBe("/"); + }); + + it.each(commands)("maps an external Vite base to the root (%s)", async command => { + const cdn = { base: "https://cdn.example.com/some/prefix/" }; + + await expect(serverBaseUrl(cdn, command)).resolves.toBe("/"); + await expect(serverBaseUrl({ base: "//cdn.example.com/" }, command)).resolves.toBe("/"); + }); + + it("prefers an explicit server.baseURL", async () => { + const config = { base: "https://cdn.example.com/", server: { baseURL: "/app/" } } as UserConfig; + + await expect(serverBaseUrl(config)).resolves.toBe("/app/"); + }); + + it("adds the missing leading slash to server.baseURL", async () => { + const config = { server: { baseURL: "app" } } as UserConfig; + + await expect(serverBaseUrl(config)).resolves.toBe("/app"); + }); +}); diff --git a/packages/start/src/fns/client.spec.ts b/packages/start/src/fns/client.spec.ts index 8054c4166..b047b9610 100644 --- a/packages/start/src/fns/client.spec.ts +++ b/packages/start/src/fns/client.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../shared/dev-toolbar/functions/tracker.ts", () => ({ pushRequest: vi.fn(), @@ -59,3 +59,42 @@ describe("fetchServerFunction", () => { await expect(callServerFunction()).resolves.toBeUndefined(); }); }); + +describe("server function URL", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + const requestedUrl = () => { + const call = (fetch as unknown as ReturnType).mock.calls[0]; + return (call![0] as Request).url; + }; + + it("posts to the app base, not to the asset base", async () => { + vi.stubEnv("BASE_URL", "https://cdn.example.com/"); + vi.stubEnv("SERVER_BASE_URL", "http://app.example.com/"); + respondWith(200); + + await callServerFunction(); + + expect(requestedUrl()).toBe("http://app.example.com/_server"); + }); + + it("exposes .url under the app base", () => { + vi.stubEnv("BASE_URL", "https://cdn.example.com/"); + vi.stubEnv("SERVER_BASE_URL", "/app/"); + + const fn = cloneServerReference("test-fn") as unknown as { url: string }; + + expect(fn.url).toBe("/app/_server?id=test-fn"); + }); + + it("adds the missing trailing slash to the app base", () => { + vi.stubEnv("BASE_URL", "https://cdn.example.com/"); + vi.stubEnv("SERVER_BASE_URL", "/app"); + + const fn = cloneServerReference("test-fn") as unknown as { url: string }; + + expect(fn.url).toBe("/app/_server?id=test-fn"); + }); +}); diff --git a/packages/start/src/fns/handler.spec.ts b/packages/start/src/fns/handler.spec.ts index 5ba67d3e2..da91c78a5 100644 --- a/packages/start/src/fns/handler.spec.ts +++ b/packages/start/src/fns/handler.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parseCookies } from "h3"; import type { FetchEvent } from "../server/types.ts"; import { getFetchEvent } from "../server/fetchEvent.ts"; @@ -336,3 +336,43 @@ describe("seroval stream response headers", () => { expect(h3Event.res.headers.get("content-type")).toBe("text/plain; charset=utf-8"); }); }); + +describe("redirects for submissions without JavaScript", () => { + const submitWithoutJS = async (result: unknown) => { + const request = new Request("http://localhost/app/_server?id=fn", { method: "POST" }); + const h3Event = { res: { headers: new Headers(), status: 200 } }; + vi.mocked(getFetchEvent).mockReturnValue({ + request, + response: { headers: { getSetCookie: () => [] } }, + nativeEvent: h3Event, + locals: {}, + } as unknown as FetchEvent); + vi.mocked(getServerFunction).mockReturnValue(() => result); + const { handleServerFunction } = await import("./handler.ts"); + return (await handleServerFunction(h3Event as never)) as Response; + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("BASE_URL", "https://cdn.example.com/"); + vi.stubEnv("SERVER_BASE_URL", "/app/"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("resolves a relative Location against the app base, not the asset base", async () => { + const redirect = new Response(null, { status: 302, headers: { Location: "dashboard" } }); + + const response = await submitWithoutJS(redirect); + + expect(response.headers.get("Location")).toBe("http://localhost/app/dashboard"); + }); + + it("sends the browser to the app root when there is no referer", async () => { + const response = await submitWithoutJS(undefined); + + expect(response.headers.get("Location")).toBe("http://localhost/app/"); + }); +}); From 164f72d767c631ed31fcf1f44879170e3cf1a438 Mon Sep 17 00:00:00 2001 From: Alexey Zakhlestin Date: Fri, 11 Sep 2026 01:20:41 +0100 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=90=9B=20Separate=20the=20app=20base?= =?UTF-8?q?=20from=20the=20asset=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite accepts `base: "https://cdn…/"` for CDN hosting, and server functions used it as the app origin. An external base says where the assets are and nothing about the app, so the app stays at the root, in dev as well: Vite reduces such a base to its path there, and 2.0.5 happened to mount the app under it. `SERVER_BASE_URL` has named the app base since #2218, but only API route matching read it, and only when `server.baseURL` was set. --- .changeset/cdn-asset-base.md | 5 +++++ packages/start/src/config/index.ts | 21 +++++++++++++++++---- packages/start/src/fns/client.spec.ts | 2 +- packages/start/src/fns/client.ts | 2 +- packages/start/src/fns/handler.ts | 9 ++++++--- packages/start/src/fns/server.ts | 2 +- packages/start/src/server/handler.ts | 2 +- 7 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 .changeset/cdn-asset-base.md diff --git a/.changeset/cdn-asset-base.md b/.changeset/cdn-asset-base.md new file mode 100644 index 000000000..25a4715b9 --- /dev/null +++ b/.changeset/cdn-asset-base.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Support a full URL in Vite's `base` so assets can be served from a CDN, completing the base URL prefixing from 2.0.3. Entry, stylesheet, modulepreload and serialized manifest paths no longer collapse `https://` into `https:/`, and server functions post to the path the app is mounted at (`server.baseURL`, else Vite's `base` when it is a plain path, else `/`) instead of the asset base. There is no CDN in development: Vite serves the assets itself, including the public directory, under the URL's path, while the app stays at the root. Root-relative links to public files therefore differ between dev and a build; setting the CDN `base` for production builds only avoids that. diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 9ad41a3f7..133e1907a 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -1,7 +1,7 @@ import { defu } from "defu"; import { globSync } from "node:fs"; import { basename, extname, isAbsolute, join } from "node:path"; -import type { PluginOption, FilterPattern } from "vite"; +import type { PluginOption, FilterPattern, UserConfig } from "vite"; import solid, { type Options as SolidOptions } from "vite-plugin-solid"; import { type ServerFunctionsOptions, serverFunctionsPlugin } from "../directives/index.ts"; import { appRootAlias } from "./app-root-alias.ts"; @@ -202,6 +202,21 @@ export interface SolidStartOptions { const absolute = (path: string, root: string) => path ? (isAbsolute(path) ? path : join(root, path)) : path; +// the same test Vite applies to an external base +const externalUrlRE = /^([a-z]+:)?\/\//; + +// Where the app is mounted, as opposed to where its assets live. Vite's base +// says where the assets are; a full URL there means a CDN, and says nothing +// about the app, which stays at the root. A plain path is shared by both. +function resolveServerBaseUrl(config: UserConfig) { + const explicit = (config.server as { baseURL?: string } | undefined)?.baseURL; + if (explicit) return explicit.startsWith("/") ? explicit : `/${explicit}`; + + const base = config.base ?? "/"; + if (externalUrlRE.test(base)) return "/"; + return new URL(base, "http://vite.dev").pathname; +} + export function solidStart(options?: SolidStartOptions): Array { const start = defu(options ?? {}, { appRoot: "./src", @@ -349,9 +364,7 @@ export function solidStart(options?: SolidStartOptions): Array { "import.meta.env.START_CLIENT_ENTRY": JSON.stringify(handlers.client), "import.meta.env.START_CLIENT_ENTRY_URL": JSON.stringify(clientEntryUrl), "import.meta.env.START_DEV_OVERLAY": JSON.stringify(start.devOverlay), - "import.meta.env.SERVER_BASE_URL": JSON.stringify( - (config.server as { baseURL?: string } | undefined)?.baseURL ?? "", - ), + "import.meta.env.SERVER_BASE_URL": JSON.stringify(resolveServerBaseUrl(config)), "import.meta.env.SEROVAL_MODE": JSON.stringify(start.serialization?.mode || "json"), }, builder: { diff --git a/packages/start/src/fns/client.spec.ts b/packages/start/src/fns/client.spec.ts index b047b9610..4ff304d91 100644 --- a/packages/start/src/fns/client.spec.ts +++ b/packages/start/src/fns/client.spec.ts @@ -37,7 +37,7 @@ const rejectionOf = async (call: Promise) => { describe("fetchServerFunction", () => { beforeEach(() => { - vi.stubEnv("BASE_URL", "http://localhost/"); + vi.stubEnv("SERVER_BASE_URL", "http://localhost/"); }); it("rejects when the response is a 5xx without an X-Error header", async () => { diff --git a/packages/start/src/fns/client.ts b/packages/start/src/fns/client.ts index 993febc3e..1a8a68ce0 100644 --- a/packages/start/src/fns/client.ts +++ b/packages/start/src/fns/client.ts @@ -101,7 +101,7 @@ async function fetchServerFunction( } export function cloneServerReference(id: string) { - let baseURL = import.meta.env.BASE_URL ?? "/"; + let baseURL = import.meta.env.SERVER_BASE_URL || "/"; if (!baseURL.endsWith("/")) baseURL += "/"; const fn = (...args: any[]) => fetchServerFunction(`${baseURL}_server`, id, {}, args); diff --git a/packages/start/src/fns/handler.ts b/packages/start/src/fns/handler.ts index bb866b606..647f8b700 100644 --- a/packages/start/src/fns/handler.ts +++ b/packages/start/src/fns/handler.ts @@ -194,7 +194,7 @@ function getRefererLocation(request: Request, url: URL) { } // no usable referer (e.g. a no-referrer policy): the app root still beats // leaving the browser sitting on the server function endpoint - return new URL(import.meta.env.BASE_URL, url.origin).toString(); + return new URL(import.meta.env.SERVER_BASE_URL || "/", url.origin).toString(); } async function handleNoJS(result: any, request: Request, parsed: any[], thrown?: boolean) { @@ -207,7 +207,10 @@ async function handleNoJS(result: any, request: Request, parsed: any[], thrown?: if (result.headers.has("Location")) { headers.set( `Location`, - new URL(result.headers.get("Location")!, url.origin + import.meta.env.BASE_URL).toString(), + new URL( + result.headers.get("Location")!, + url.origin + (import.meta.env.SERVER_BASE_URL || "/"), + ).toString(), ); statusCode = getExpectedRedirectStatus(result); } else { @@ -299,7 +302,7 @@ async function handleSingleFlight(sourceEvent: FetchEvent, result: any): Promise if (result.headers.has("Location")) url = new URL( result.headers.get("Location")!, - new URL(sourceEvent.request.url).origin + import.meta.env.BASE_URL, + new URL(sourceEvent.request.url).origin + (import.meta.env.SERVER_BASE_URL || "/"), ).toString(); } const event = { ...sourceEvent } as PageEvent; diff --git a/packages/start/src/fns/server.ts b/packages/start/src/fns/server.ts index de6ecd8a8..1f89ca9f7 100644 --- a/packages/start/src/fns/server.ts +++ b/packages/start/src/fns/server.ts @@ -19,7 +19,7 @@ export function createServerReference( export function cloneServerReference({ id, fn }: Registration) { if (typeof fn !== "function") throw new Error("Export from a 'use server' module must be a function"); - let baseURL = import.meta.env.BASE_URL ?? "/"; + let baseURL = import.meta.env.SERVER_BASE_URL || "/"; if (!baseURL.endsWith("/")) baseURL += "/"; return new Proxy(fn, { diff --git a/packages/start/src/server/handler.ts b/packages/start/src/server/handler.ts index 18f2ddca5..663384377 100644 --- a/packages/start/src/server/handler.ts +++ b/packages/start/src/server/handler.ts @@ -225,6 +225,6 @@ function escapeAttribute(value: string) { } function stripBaseUrl(path: string) { - const base = import.meta.env.SERVER_BASE_URL || import.meta.env.BASE_URL || "/"; + const base = import.meta.env.SERVER_BASE_URL || "/"; return stripPathBase(path, base); } From 4ed1afc16307a2021f7800a6cbfa99d2c4791c02 Mon Sep 17 00:00:00 2001 From: Alexey Zakhlestin Date: Fri, 11 Sep 2026 11:03:03 +0100 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20Wrap=20the=20app=20base=20in?= =?UTF-8?q?=20slashes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relative redirect locations are resolved against origin + base, and `new URL("page", "http://host/app")` gives /page, dropping the mount segment. The base here comes from the user config, before Vite normalizes it. --- packages/start/src/config/index.spec.ts | 8 ++++++-- packages/start/src/config/index.ts | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/start/src/config/index.spec.ts b/packages/start/src/config/index.spec.ts index 9e387a382..4eec36f04 100644 --- a/packages/start/src/config/index.spec.ts +++ b/packages/start/src/config/index.spec.ts @@ -71,9 +71,13 @@ describe("SERVER_BASE_URL", () => { await expect(serverBaseUrl(config)).resolves.toBe("/app/"); }); - it("adds the missing leading slash to server.baseURL", async () => { + it("wraps server.baseURL in slashes", async () => { const config = { server: { baseURL: "app" } } as UserConfig; - await expect(serverBaseUrl(config)).resolves.toBe("/app"); + await expect(serverBaseUrl(config)).resolves.toBe("/app/"); + }); + + it.each(commands)("adds the trailing slash a Vite base may lack (%s)", async command => { + await expect(serverBaseUrl({ base: "/app" }, command)).resolves.toBe("/app/"); }); }); diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 133e1907a..39f3eed52 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -205,16 +205,22 @@ const absolute = (path: string, root: string) => // the same test Vite applies to an external base const externalUrlRE = /^([a-z]+:)?\/\//; +// A mount path is always wrapped in slashes: `new URL("page", origin + "/app")` +// resolves to /page, losing the segment, and a bare "app/" would glue onto +// the origin. +const withSlashes = (path: string) => + `${path.startsWith("/") ? "" : "/"}${path}${path.endsWith("/") ? "" : "/"}`; + // Where the app is mounted, as opposed to where its assets live. Vite's base // says where the assets are; a full URL there means a CDN, and says nothing // about the app, which stays at the root. A plain path is shared by both. function resolveServerBaseUrl(config: UserConfig) { const explicit = (config.server as { baseURL?: string } | undefined)?.baseURL; - if (explicit) return explicit.startsWith("/") ? explicit : `/${explicit}`; + if (explicit) return withSlashes(explicit); const base = config.base ?? "/"; if (externalUrlRE.test(base)) return "/"; - return new URL(base, "http://vite.dev").pathname; + return withSlashes(new URL(base, "http://vite.dev").pathname); } export function solidStart(options?: SolidStartOptions): Array { From e8d74310ec8eb97bb82f2e36d7153197e4fcac76 Mon Sep 17 00:00:00 2001 From: Alexey Zakhlestin Date: Fri, 11 Sep 2026 01:20:41 +0100 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20Publish=20the=20SER?= =?UTF-8?q?VER=5FBASE=5FURL=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps pass it to the router as `base`, see https://docs.solidjs.com/solid-start/building-your-application/routing, but it was declared only in the package's internal env.d.ts, so apps saw it as `any`. --- packages/start/env.d.ts | 9 +++++++++ packages/start/src/env.d.ts | 1 - 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/start/env.d.ts b/packages/start/env.d.ts index c638b96cd..406491cf9 100644 --- a/packages/start/env.d.ts +++ b/packages/start/env.d.ts @@ -19,3 +19,12 @@ declare module "server-only" {} * Importing it in a server module will throw a build error. */ declare module "client-only" {} + +// Merges into the ImportMetaEnv that vite/client declares. +interface ImportMetaEnv { + /** + * Path the app is mounted at, always wrapped in slashes: `server.baseURL` (a path, not + * a URL), else Vite's `base` when it is a plain path, else `/`. + */ + SERVER_BASE_URL: string; +} diff --git a/packages/start/src/env.d.ts b/packages/start/src/env.d.ts index 43617f4ea..da45f74d6 100644 --- a/packages/start/src/env.d.ts +++ b/packages/start/src/env.d.ts @@ -14,5 +14,4 @@ interface SolidStartMetaEnv { START_CLIENT_ENTRY_URL: string; START_ISLANDS: boolean; // START_DEV_OVERLAY: boolean; - SERVER_BASE_URL: string; } From 45001d8e6bd2155e2a03c423702d7062cda3a7ae Mon Sep 17 00:00:00 2001 From: Alexey Zakhlestin Date: Fri, 11 Sep 2026 08:19:31 +0100 Subject: [PATCH 6/6] =?UTF-8?q?=E2=9C=85=20Test=20the=20app=20with=20a=20C?= =?UTF-8?q?DN=20asset=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passing `base` to the router is what an app under a path needs; at `/` it is a no-op, so the existing suites are unaffected. --- apps/tests/package.json | 3 +- apps/tests/playwright.base.config.ts | 26 ++++++++++++++ apps/tests/src/app.tsx | 1 + .../tests/src/e2e-base/cdn-asset-base.test.ts | 36 +++++++++++++++++++ apps/tests/vite.config.base.ts | 7 ++++ apps/tests/vitest.config.ts | 2 +- 6 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 apps/tests/playwright.base.config.ts create mode 100644 apps/tests/src/e2e-base/cdn-asset-base.test.ts create mode 100644 apps/tests/vite.config.base.ts diff --git a/apps/tests/package.json b/apps/tests/package.json index 5f7b2d082..72ce2d1d6 100644 --- a/apps/tests/package.json +++ b/apps/tests/package.json @@ -11,8 +11,9 @@ "unit:ci": "vitest run", "e2e": "playwright test", "e2e:bundled-dev": "playwright test --config playwright.bundled-dev.config.ts", + "e2e:base": "playwright test --config playwright.base.config.ts", "e2e:ui": "playwright test --ui", - "test:all": "pnpm run unit:ci && pnpm run e2e && pnpm run e2e:bundled-dev" + "test:all": "pnpm run unit:ci && pnpm run e2e && pnpm run e2e:bundled-dev && pnpm run e2e:base" }, "dependencies": { "@solidjs/meta": "^0.29.4", diff --git a/apps/tests/playwright.base.config.ts b/apps/tests/playwright.base.config.ts new file mode 100644 index 000000000..318731b0a --- /dev/null +++ b/apps/tests/playwright.base.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./src/e2e-base", + testMatch: "**/*.test.ts", + + webServer: { + command: "pnpm run dev --config vite.config.base.ts --host 127.0.0.1 --port 3001 --strictPort", + url: "http://127.0.0.1:3001", + reuseExistingServer: true, + stdout: "pipe", + stderr: "pipe", + }, + + use: { + baseURL: "http://127.0.0.1:3001", + trace: "on-first-retry", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/apps/tests/src/app.tsx b/apps/tests/src/app.tsx index fe5146624..69d69f6ba 100644 --- a/apps/tests/src/app.tsx +++ b/apps/tests/src/app.tsx @@ -7,6 +7,7 @@ import "./app.css"; export default function App() { return ( ( SolidStart - Basic diff --git a/apps/tests/src/e2e-base/cdn-asset-base.test.ts b/apps/tests/src/e2e-base/cdn-asset-base.test.ts new file mode 100644 index 000000000..111151c16 --- /dev/null +++ b/apps/tests/src/e2e-base/cdn-asset-base.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; + +test.describe("app with a CDN asset base", () => { + test("serves the page at the root and its entry script under the asset base", async ({ + page, + }) => { + const response = await page.goto("/"); + + expect(response?.status()).toBe(200); + await expect(page.locator('script[type="module"][src]').first()).toHaveAttribute( + "src", + /^\/some\/prefix\//, + ); + }); + + test("calls server functions at the root, not under the asset base", async ({ page }) => { + const serverFunctionCalls: string[] = []; + page.on("request", request => { + if (request.url().includes("_server")) serverFunctionCalls.push(request.url()); + }); + + await page.goto("/is-server-nested"); + + await expect(page.locator("#server-fn-test")).toContainText('{"serverFnWithIsServer":true}'); + expect(serverFunctionCalls.length).toBeGreaterThan(0); + for (const url of serverFunctionCalls) { + expect(new URL(url).pathname).toMatch(/^\/_server/); + } + }); + + test("matches API routes at the root", async () => { + const response = await fetch("http://127.0.0.1:3001/api/text-plain"); + + expect(await response.text()).toBe("test"); + }); +}); diff --git a/apps/tests/vite.config.base.ts b/apps/tests/vite.config.base.ts new file mode 100644 index 000000000..2f0de17f3 --- /dev/null +++ b/apps/tests/vite.config.base.ts @@ -0,0 +1,7 @@ +import { defineConfig, mergeConfig } from "vite"; +import config from "./vite.config.ts"; + +// A CDN base, as a production build would set it, kept for dev on purpose: dev +// has no CDN, so Vite serves the assets itself under the URL's path, /some/prefix/, +// while the app stays at the root, as it would with the assets on the CDN. +export default mergeConfig(config, defineConfig({ base: "https://cdn.example.com/some/prefix/" })); diff --git a/apps/tests/vitest.config.ts b/apps/tests/vitest.config.ts index 6173426cf..b1b7ff949 100644 --- a/apps/tests/vitest.config.ts +++ b/apps/tests/vitest.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ test: { mockReset: true, globals: true, - exclude: [...configDefaults.exclude, "**/src/e2e/**"], + exclude: [...configDefaults.exclude, "**/src/e2e/**", "**/src/e2e-base/**"], projects: [ { // 1. NODE Project (For fs, tree-shaking, server utilities)