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/keep-client-conditions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/start": patch
---

Keep Vite's default resolve conditions ("module", "browser") in the client environment: since 2.1.0, packages exposing a "browser" export condition (e.g. `@sentry/solidstart`) resolved to their Node build in the browser. The dev overlay's `@jridgewell/trace-mapping` is now pre-bundled, as the "browser" condition makes it import a UMD build.
5 changes: 5 additions & 0 deletions .changeset/server-function-skew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/start": minor
---

Add `serverFunctions.skewProtection`. When it is on, a page loaded before a deploy can no longer call the new server's functions with an outdated client. The server answers such a call with `409` instead of running the function, and the page reloads once to load the new version. Each build gets a random ID, or pass `{ buildId }` (such as a commit hash) when several machines build the same deploy. Only applies to production builds, and is off by default.
5 changes: 5 additions & 0 deletions .changeset/server-function-uploads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/start": minor
---

Let server functions take files and blobs anywhere in their arguments, not only as the only argument. A call like `upload(file, { tags })`, or one with files in arrays, objects, maps, sets or form data, sends them as parts of multipart form data next to the serialized arguments, and the server function gets them as `File` values. Calls without files are sent as before.
5 changes: 5 additions & 0 deletions .changeset/server-functions-real-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/start": patch
---

Resolve the server function filter and the ids of lazily loaded modules against Vite's root instead of the working directory. On Windows a working directory entered through a junction or symlink keeps that path, while Vite (8.2 and later) resolves the root and every module id to the real path. The filter then matched no module, so no `"use server"` function was compiled: its body shipped in the client bundle, and the build failed with `Attempt to import 'server-only' in a client module` once one of them imported server-only code such as `@solidjs/start/http`. The ids of lazily loaded route components matched no client manifest entry either, so their CSS was missing from the server-rendered HTML. Relative patterns in the `serverFunctions.filter` option now resolve against Vite's root too.
12 changes: 12 additions & 0 deletions apps/tests/src/e2e/server-function.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ test.describe("server-function", () => {
await page.goto("http://localhost:3000/server-function-blob");
await expect(page.locator("#server-fn-test")).toContainText('{"result":true}');
});
test("should send files next to other arguments", async ({ page }) => {
await page.goto("http://localhost:3000/server-function-upload");
await expect(page.locator("#server-fn-test")).toContainText(
JSON.stringify({
name: "hello-world.txt",
text: "Hello, World!",
tags: ["a", "b"],
attachments: ["one", "two"],
avatar: "avatar.png:png",
}),
);
});
test("should remove exports for non-function values when top-level use server is used", async ({
page,
}) => {
Expand Down
36 changes: 36 additions & 0 deletions apps/tests/src/routes/server-function-upload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createEffect, createSignal } from "solid-js";

async function upload(file: File, meta: { tags: string[]; attachments: Blob[] }, form: FormData) {
"use server";
const attachments = await Promise.all(meta.attachments.map(blob => blob.text()));
const avatar = form.get("avatar") as File;
return {
name: file.name,
text: await file.text(),
tags: meta.tags,
attachments,
avatar: `${avatar.name}:${await avatar.text()}`,
};
}

export default function App() {
const [output, setOutput] = createSignal<unknown>();

createEffect(async () => {
const form = new FormData();
form.set("avatar", new File(["png"], "avatar.png", { type: "image/png" }));
setOutput(
await upload(
new File(["Hello, World!"], "hello-world.txt"),
{ tags: ["a", "b"], attachments: [new Blob(["one"]), new Blob(["two"])] },
form,
),
);
});

return (
<main>
<span id="server-fn-test">{JSON.stringify(output())}</span>
</main>
);
}
75 changes: 75 additions & 0 deletions packages/start/src/config/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,78 @@ describe("SERVER_BASE_URL", () => {
await expect(serverBaseUrl({ base: "/app" }, command)).resolves.toBe("/app/");
});
});

describe("resolve conditions", () => {
async function resolveEnvironments(command: ConfigEnv["command"] = "serve") {
const { resolveConfig } = await import("vite");
const config = await resolveConfig(
{ root, logLevel: "silent", plugins: [solidStart()] },
command,
command === "build" ? "production" : "development",
);
return config.environments;
}

it("keeps Vite's client conditions", async () => {
const { client } = await resolveEnvironments();

expect(client.resolve.conditions).toEqual(expect.arrayContaining(["module", "browser"]));
expect(client.resolve.conditions).not.toContain("generic");
});

it("resolves the server to runtime-agnostic builds", async () => {
const { ssr } = await resolveEnvironments();

expect(ssr.resolve.conditions).toContain("generic");
expect(ssr.resolve.conditions).not.toContain("node");
});

it("pre-bundles the dev overlay's dependencies in dev only", async () => {
const dev = await resolveEnvironments("serve");
const build = await resolveEnvironments("build");

expect(dev.client.optimizeDeps.include).toContain("@solidjs/start > @jridgewell/trace-mapping");
expect(build.client.optimizeDeps.include ?? []).not.toContain(
"@solidjs/start > @jridgewell/trace-mapping",
);
});
});

describe("START_BUILD_ID", () => {
async function buildId(
options: Parameters<typeof solidStart>[0],
command: ConfigEnv["command"] = "build",
) {
const plugin = solidStart(options)
.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({}, { command, mode });
return JSON.parse(resolved.define!["import.meta.env.START_BUILD_ID"] as string) as string;
}

it("is empty when skew protection is off", async () => {
await expect(buildId(undefined)).resolves.toBe("");
});

it("is empty in dev", async () => {
await expect(buildId({ serverFunctions: { skewProtection: true } }, "serve")).resolves.toBe("");
});

it("is random per build", async () => {
const a = await buildId({ serverFunctions: { skewProtection: true } });
const b = await buildId({ serverFunctions: { skewProtection: true } });
expect(a).not.toBe("");
expect(a).not.toBe(b);
});

it("uses the configured build ID", async () => {
await expect(
buildId({ serverFunctions: { skewProtection: { buildId: "abc123" } } }),
).resolves.toBe("abc123");
});
});
64 changes: 62 additions & 2 deletions packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defu } from "defu";
import { randomUUID } from "node:crypto";
import { globSync } from "node:fs";
import { basename, extname, isAbsolute, join } from "node:path";
import type { PluginOption, FilterPattern, UserConfig } from "vite";
Expand Down Expand Up @@ -221,9 +222,41 @@ export interface SolidStartOptions {
* @example "src/server-fn-error.ts"
*/
onError?: string;

/**
* Reject server function calls from a page loaded before the latest
* deploy, and reload that page so it runs the new version.
*
* Each production build gets an ID that is baked into both the client and
* the server. The client sends its ID with every server function call. A
* server with a different ID answers with `409` instead of running the
* function, and the client reloads the page once. A call that still fails
* right after the reload rejects instead of reloading again.
*
* Every deploy invalidates every open page this way, even when the
* function did not change, because a changed argument or return type
* cannot be told apart from an unchanged one.
*
* Requests without the ID, such as no-JS form submissions or
* pages built before this option was turned on, are not checked.
*
* Pass `{ buildId }` when several machines build the same code for one
* deploy, such as a commit hash, so their IDs match. Otherwise each build
* gets a random ID.
*
* Only applies to production builds.
*
* @default false
*/
skewProtection?: boolean | { buildId?: string };
};
}

// The dev overlay is served unbundled (solid packages are excluded from
// pre-bundling), and with the "browser" condition @jridgewell/trace-mapping
// imports the UMD build of @jridgewell/resolve-uri, which has no ESM exports.
const DEV_OVERLAY_DEPENDENCIES = ["@solidjs/start > @jridgewell/trace-mapping"];

const absolute = (path: string, root: string) =>
path ? (isAbsolute(path) ? path : join(root, path)) : path;

Expand Down Expand Up @@ -251,6 +284,16 @@ function resolveServerBaseUrl(config: UserConfig) {
return withSlashes(new URL(base, "http://vite.dev").pathname);
}

// An empty ID turns the check off on both sides.
function resolveBuildId(
skewProtection: NonNullable<SolidStartOptions["serverFunctions"]>["skewProtection"],
command: "build" | "serve",
fallback: string,
) {
if (!skewProtection || command !== "build") return "";
return (typeof skewProtection === "object" && skewProtection.buildId) || fallback;
}

export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
const start = defu(options ?? {}, {
appRoot: "./src",
Expand All @@ -273,6 +316,8 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
throw new Error(`Could not find an app jsx/tsx entry in ${start.appRoot}.`);
}
const entryExtension = extname(appEntryPath);
// created once, so every environment of the build gets the same ID
const randomBuildId = randomUUID();
const handlers = {
client: `${start.appRoot}/entry-client${entryExtension}`,
server: `${start.appRoot}/entry-server${entryExtension}`,
Expand All @@ -293,10 +338,16 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
{
name: "solid-start:config",
enforce: "pre",
configEnvironment(name) {
configEnvironment(name, config) {
// Setting resolve.conditions replaces Vite's defaults instead of extending
// them. Server environments drop "node" on purpose, so that h3 resolves to
// its runtime-agnostic build, but the client must keep "browser" and
// "module": without them, packages exposing a "browser" export condition
// resolve to their Node build in the browser.
const isClient = name === VITE_ENVIRONMENTS.client || config.consumer === "client";
return {
resolve: {
conditions: ["generic"],
...(isClient ? {} : { conditions: ["generic"] }),
// remove when https://github.com/solidjs/vite-plugin-solid/pull/228 is released
externalConditions: ["solid", "node"],
},
Expand All @@ -308,6 +359,11 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
env.command === "serve" && config.experimental?.bundledDev
? `assets/${basename(handlers.client, entryExtension)}.js`
: handlers.client;
const buildId = resolveBuildId(
options?.serverFunctions?.skewProtection,
env.command,
randomBuildId,
);
if (env.command === "build") {
const clientRouter: BaseFileSystemRouter = (globalThis as any).ROUTERS.client;
for (const route of await clientRouter.getRoutes()) {
Expand Down Expand Up @@ -343,6 +399,9 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
environments: {
[VITE_ENVIRONMENTS.client]: {
consumer: "client",
...(env.command === "serve" && start.devOverlay
? { optimizeDeps: { include: DEV_OVERLAY_DEPENDENCIES } }
: {}),
build: {
write: true,
manifest: true,
Expand Down Expand Up @@ -408,6 +467,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
),
"import.meta.env.SERVER_BASE_URL": JSON.stringify(resolveServerBaseUrl(config)),
"import.meta.env.SEROVAL_MODE": JSON.stringify(start.serialization?.mode || "json"),
"import.meta.env.START_BUILD_ID": JSON.stringify(buildId),
},
builder: {
sharedPlugins: true,
Expand Down
27 changes: 24 additions & 3 deletions packages/start/src/config/lazy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";

import lazy from "./lazy.ts";

async function transform(code: string, id = `${process.cwd()}/src/routes/page.tsx`) {
async function transform(
code: string,
id = `${process.cwd()}/src/routes/page.tsx`,
root = process.cwd(),
) {
const plugin = lazy() as any;
const context = { environment: { name: "ssr" } };
const context = { environment: { name: "ssr", config: { root } } };
return plugin.transform.call(context, code, id);
}

afterEach(() => {
vi.restoreAllMocks();
});

describe("lazy", () => {
it("appends the module id without moving existing code", async () => {
const code = `import { A } from "./a.ts";\nexport default function Page() { return A; }`;
Expand All @@ -29,6 +37,19 @@ describe("lazy", () => {
expect(result.map).toBeTruthy();
});

it("names the module relative to the root when the working directory is a link to it", async () => {
// Vite (8.2 and later) resolves its root and module ids to the real path.
// On Windows the working directory keeps the junction or symlink path.
vi.spyOn(process, "cwd").mockReturnValue("/link/app");
const result = await transform(
`import { A } from "./a.ts";\nexport default A;`,
"/real/app/src/routes/page.tsx",
"/real/app",
);

expect(result.code).toContain(`export const id$$ = "src/routes/page.tsx";`);
});

it("skips a module without a default export or lazy", async () => {
expect(await transform(`import { A } from "./a.ts";\nexport const B = A;`)).toBeUndefined();
});
Expand Down
14 changes: 8 additions & 6 deletions packages/start/src/config/lazy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { PluginItem } from "@babel/core";
import babel from "@babel/core";
import * as t from "@babel/types";
import { sep as osSep } from "node:path";
import { basename, relative, sep } from "node:path/posix";
import type { PluginOption } from "vite";
import { relative } from "node:path";
import { basename } from "node:path/posix";
import { normalizePath, type PluginOption } from "vite";
import { VITE_ENVIRONMENTS } from "./constants.ts";

const importTransform = (): PluginItem => {
Expand Down Expand Up @@ -31,8 +31,6 @@ const importTransform = (): PluginItem => {
const fileEndingRegex = /(ts|js)x(\?.*)?$/;

const lazy = (): PluginOption => {
const cwd = process.cwd().replaceAll(osSep, sep);

/**
* Maps module ids to their client-specific shared chunk names.
* Modules in shared chunks need to find their assets via the chunk name, instead of their module id.
Expand Down Expand Up @@ -84,7 +82,11 @@ const lazy = (): PluginOption => {
// source map of the input still applies to the output.
let idExport = "";
if (src.indexOf("export default") !== -1) {
const chunkName = sharedChunkNames[id] ?? relative(cwd, id);
// Derived the way Vite derives the client manifest keys it is looked
// up by. Relative to the working directory, it would match no key when
// that directory reaches the project through a junction or symlink.
const chunkName =
sharedChunkNames[id] ?? normalizePath(relative(this.environment.config.root, id));
idExport = `\nexport const id$$ = ${JSON.stringify(chunkName)};\n`;
}

Expand Down
Loading
Loading