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, 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.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); + } + }); +}); 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" }), },