Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/cdn-asset-base.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion apps/tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions apps/tests/playwright.base.config.ts
Original file line number Diff line number Diff line change
@@ -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"] },
},
],
});
1 change: 1 addition & 0 deletions apps/tests/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import "./app.css";
export default function App() {
return (
<Router
base={import.meta.env.SERVER_BASE_URL}
root={props => (
<MetaProvider>
<Title>SolidStart - Basic</Title>
Expand Down
36 changes: 36 additions & 0 deletions apps/tests/src/e2e-base/cdn-asset-base.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
7 changes: 7 additions & 0 deletions apps/tests/vite.config.base.ts
Original file line number Diff line number Diff line change
@@ -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/" }));
2 changes: 1 addition & 1 deletion apps/tests/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions packages/start/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
83 changes: 83 additions & 0 deletions packages/start/src/config/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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<UserConfig>;
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/");
});
});
27 changes: 23 additions & 4 deletions packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<PluginOption> {
const start = defu(options ?? {}, {
appRoot: "./src",
Expand Down Expand Up @@ -349,9 +370,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
"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: {
Expand Down
1 change: 0 additions & 1 deletion packages/start/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@ interface SolidStartMetaEnv {
START_CLIENT_ENTRY_URL: string;
START_ISLANDS: boolean;
// START_DEV_OVERLAY: boolean;
SERVER_BASE_URL: string;
}
43 changes: 41 additions & 2 deletions packages/start/src/fns/client.spec.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -37,7 +37,7 @@ const rejectionOf = async (call: Promise<unknown>) => {

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 () => {
Expand All @@ -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<typeof vi.fn>).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");
});
});
2 changes: 1 addition & 1 deletion packages/start/src/fns/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
42 changes: 41 additions & 1 deletion packages/start/src/fns/handler.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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/");
});
});
Loading
Loading