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/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) 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/config/index.spec.ts b/packages/start/src/config/index.spec.ts new file mode 100644 index 000000000..4eec36f04 --- /dev/null +++ b/packages/start/src/config/index.spec.ts @@ -0,0 +1,83 @@ +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("wraps server.baseURL in slashes", async () => { + const config = { server: { baseURL: "app" } } as UserConfig; + + 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 9ad41a3f7..39f3eed52 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,27 @@ 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]+:)?\/\//; + +// 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 withSlashes(explicit); + + const base = config.base ?? "/"; + if (externalUrlRE.test(base)) return "/"; + return withSlashes(new URL(base, "http://vite.dev").pathname); +} + export function solidStart(options?: SolidStartOptions): Array { const start = defu(options ?? {}, { appRoot: "./src", @@ -349,9 +370,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/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; } diff --git a/packages/start/src/fns/client.spec.ts b/packages/start/src/fns/client.spec.ts index 8054c4166..4ff304d91 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(), @@ -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 () => { @@ -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/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.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/"); + }); +}); 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); } 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" }), },