From 0147d1752251df4055d18f29c1f7b06f215fc116 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 31 May 2026 08:59:09 +0200 Subject: [PATCH 1/3] test(start): fail when prod SSR manifest ignores deploy base Asserts getSsrProdManifest().path() / .getAssets() / .json() emit URLs prefixed with import.meta.env.BASE_URL. Currently red: every emitted path is rooted at '/', so subpath-deployed apps load script and CSS tags from the wrong origin path. --- .../server/manifest/prod-ssr-manifest.spec.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 packages/start/src/server/manifest/prod-ssr-manifest.spec.ts diff --git a/packages/start/src/server/manifest/prod-ssr-manifest.spec.ts b/packages/start/src/server/manifest/prod-ssr-manifest.spec.ts new file mode 100644 index 000000000..a48839463 --- /dev/null +++ b/packages/start/src/server/manifest/prod-ssr-manifest.spec.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Fixture: a vite manifest that exposes one entry chunk, one dynamic-entry +// route chunk with a stylesheet, and one nested import-only chunk. +const fixtureManifest = { + "src/entry-client.tsx": { + file: "assets/entry-client-abc.js", + isEntry: true, + css: ["assets/entry-client-xyz.css"], + imports: ["_shared.js"], + }, + "_shared.js": { + file: "assets/shared-def.js", + css: [], + }, + "src/routes/foo.tsx": { + file: "assets/foo-ghi.js", + isDynamicEntry: true, + css: ["assets/foo-jkl.css"], + imports: ["_shared.js"], + }, +}; + +vi.mock("solid-start:client-vite-manifest", () => ({ + clientViteManifest: fixtureManifest, +})); + +async function loadModule() { + // Top-level reads of import.meta.env need to be captured at import time, + // so isolate the module cache to pick up the current stubs. + vi.resetModules(); + return await import("./prod-ssr-manifest.ts"); +} + +describe("getSsrProdManifest under a deploy base", () => { + beforeEach(() => { + vi.stubEnv("BASE_URL", "/sub/"); + vi.stubEnv("START_CLIENT_ENTRY", "./src/entry-client.tsx"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("path(id) returns the asset URL prefixed with BASE_URL", async () => { + const { getSsrProdManifest } = await loadModule(); + const m = getSsrProdManifest(); + + expect(m.path("src/entry-client.tsx")).toBe("/sub/assets/entry-client-abc.js"); + }); + + it("path(id) normalizes a leading './' the same as the unprefixed form", async () => { + const { getSsrProdManifest } = await loadModule(); + const m = getSsrProdManifest(); + + expect(m.path("./src/entry-client.tsx")).toBe("/sub/assets/entry-client-abc.js"); + }); + + it("getAssets(id) returns asset hrefs prefixed with BASE_URL", async () => { + const { getSsrProdManifest } = await loadModule(); + const m = getSsrProdManifest(); + const assets = await m.getAssets("src/routes/foo.tsx"); + const hrefs = assets.map(a => a.attrs.href as string).sort(); + + // Every emitted href must sit under the deploy base; none may be + // root-absolute at "/assets/...". + for (const href of hrefs) { + expect(href.startsWith("/sub/")).toBe(true); + } + }); + + it("json() serializes each entry with a base-prefixed output path", async () => { + const { getSsrProdManifest } = await loadModule(); + const m = getSsrProdManifest(); + const json = await m.json(); + + expect(json["src/entry-client.tsx"].output).toBe("/sub/assets/entry-client-abc.js"); + expect(json["src/routes/foo.tsx"].output).toBe("/sub/assets/foo-ghi.js"); + }); +}); + +describe("getSsrProdManifest with BASE_URL = '/' (no subpath)", () => { + beforeEach(() => { + vi.stubEnv("BASE_URL", "/"); + vi.stubEnv("START_CLIENT_ENTRY", "./src/entry-client.tsx"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("path(id) returns a root-absolute URL (no '//' prefix)", async () => { + const { getSsrProdManifest } = await loadModule(); + const m = getSsrProdManifest(); + + expect(m.path("src/entry-client.tsx")).toBe("/assets/entry-client-abc.js"); + }); + + it("getAssets(id) returns single-slash hrefs", async () => { + const { getSsrProdManifest } = await loadModule(); + const m = getSsrProdManifest(); + const assets = await m.getAssets("src/routes/foo.tsx"); + + for (const a of assets) { + const href = a.attrs.href as string; + expect(href.startsWith("//")).toBe(false); + expect(href.startsWith("/")).toBe(true); + } + }); +}); From e872ac824a4cf2ed64abc64dae9cf26e954668ff Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 31 May 2026 09:00:14 +0200 Subject: [PATCH 2/3] fix(start): thread BASE_URL through SSR manifest paths The prod SSR manifest hard-coded '/' as the prefix for entry script src, modulepreload/stylesheet hrefs, and the serialized window.manifest output paths. Under a deploy base ('/repo/' on GitHub Pages, etc.) every emitted URL pointed at the wrong origin path, so the prerendered HTML loaded nothing and the app failed to hydrate. Replace '/' with import.meta.env.BASE_URL via pathe.join, matching the dev-ssr-manifest's existing base-aware joins. Apply the same fix to the dev-client-manifest's dynamic import. --- packages/start/src/server/manifest/dev-client-manifest.ts | 2 +- packages/start/src/server/manifest/prod-ssr-manifest.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/start/src/server/manifest/dev-client-manifest.ts b/packages/start/src/server/manifest/dev-client-manifest.ts index 480693955..7c8e488e1 100644 --- a/packages/start/src/server/manifest/dev-client-manifest.ts +++ b/packages/start/src/server/manifest/dev-client-manifest.ts @@ -3,7 +3,7 @@ import { join } from "pathe"; export function getClientDevManifest() { return { import(id) { - return import(/* @vite-ignore */ join("/", id)); + return import(/* @vite-ignore */ join(import.meta.env.BASE_URL, id)); }, async getAssets(id) { const assetsPath = `/@manifest/client/${Date.now()}/assets?id=${id}`; diff --git a/packages/start/src/server/manifest/prod-ssr-manifest.ts b/packages/start/src/server/manifest/prod-ssr-manifest.ts index 89d4f02ef..597adc788 100644 --- a/packages/start/src/server/manifest/prod-ssr-manifest.ts +++ b/packages/start/src/server/manifest/prod-ssr-manifest.ts @@ -13,7 +13,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("/", viteManifestEntry.file); + return join(import.meta.env.BASE_URL, viteManifestEntry.file); }, async getAssets(id) { if (id.startsWith("./")) id = id.slice(2); @@ -29,7 +29,7 @@ export function getSsrProdManifest() { for (const entryKey of entryKeys) { json[entryKey] = { - output: join("/", viteManifest[entryKey]!.file), + output: join(import.meta.env.BASE_URL, viteManifest[entryKey]!.file), assets: await this.getAssets(entryKey), }; } @@ -54,7 +54,7 @@ function createHtmlTagsForAssets(assets: string[]) { .map(asset => ({ tag: "link", attrs: { - href: "/" + asset, + href: join(import.meta.env.BASE_URL, asset), key: asset, ...(asset.endsWith(".css") ? { rel: "stylesheet" } : { rel: "modulepreload" }), }, From 4bb7d84e0683291b8f966065100310990b1b4af0 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Sun, 31 May 2026 09:00:57 +0200 Subject: [PATCH 3/3] fix(vite-plugin-nitro-2): inherit Vite config.base as the Nitro baseURL Today the plugin defaults Nitro's baseURL to '/' regardless of the project's Vite base. On a subpath deploy ('base: "/repo/"' for GitHub Pages, etc.) Nitro then prerenders, route-prefixes, and serves at the wrong path, forcing users to pass the prefix to both Vite and the plugin. Capture the resolved Vite base in configResolved and use it as the nitroConfig.baseURL default. A user-supplied nitroV2Plugin({ baseURL }) is still honored because the spread comes after the default. --- packages/start-nitro-v2-vite-plugin/src/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/start-nitro-v2-vite-plugin/src/index.ts b/packages/start-nitro-v2-vite-plugin/src/index.ts index cc2ec4a55..1f6369a2c 100644 --- a/packages/start-nitro-v2-vite-plugin/src/index.ts +++ b/packages/start-nitro-v2-vite-plugin/src/index.ts @@ -13,6 +13,7 @@ import type { PluginOption, Rollup } from "vite"; let ssrBundle: Rollup.OutputBundle; let ssrEntryFile: string; +let viteBase: string = "/"; export type UserNitroConfig = Omit; @@ -20,6 +21,12 @@ export function nitroV2Plugin(nitroConfig?: UserNitroConfig): PluginOption { return [ { name: "solid-start-vite-plugin-nitro", + configResolved(config) { + // Capture the resolved Vite base so the Nitro build can default its + // baseURL to the same subpath. A user-supplied nitroConfig.baseURL + // still wins; this just removes the need to set the same prefix twice. + viteBase = config.base ?? "/"; + }, generateBundle: { handler(_options, bundle) { if (this.environment.name !== "ssr") { @@ -78,6 +85,7 @@ export function nitroV2Plugin(nitroConfig?: UserNitroConfig): PluginOption { compatibilityDate: "2024-11-19", logLevel: 3, preset: "node-server", + baseURL: viteBase, typescript: { generateTsConfig: false, generateRuntimeConfigTypes: false,